OpenCV 형상 인식 - OpenCV hyeongsang insig

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

OpenCV - 1. 파이썬으로 만드는 OpenCV 프로젝트

Show

지금까지 데이터 분석 - 머신러닝 - 딥러닝 - 자연어 처리를 공부했습니다. 이것들에 이어 앞으로는 컴퓨터 비전 공부를 하려고 합니다. 사실 자연어 처리도 얕게 공부한 수준이긴 합니다. 이런

bkshin.tistory.com

OpenCV 형상 인식 - OpenCV hyeongsang insig

https://bkshin.tistory.com/entry/OpenCV-33-HOG-%EB%94%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%84%B0HOG-Descriptor?category=1148027 

OpenCV - 33. HOG(Histogram of Oriented Gradient) 디스크립터

이번 포스팅에서는 HOG 디스크립터에 대해 알아보겠습니다. 이번에도 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리했습니다. 기울기 벡터(Gradient Vectors) 기울기 벡터란 영상 내 하나의

bkshin.tistory.com

OpenCV 형상 인식 - OpenCV hyeongsang insig

box_detection.html

0.61MB

상세한 내용 및 디테일 등에 대해선 OpenCV 공식 도큐멘테이션 순서를 따라가도 비슷한 예제들로 구성되어 있습니다.

원문 블로그 및 OpenCV프로젝트(이세우 저) 책을 통해서 확인해주세요.

Open CV 공식 도큐멘테이션

https://docs.opencv.org/4.x/d9/df8/tutorial_root.html

OpenCV: OpenCV Tutorials

OpenCV  4.5.5-dev Open Source Computer Vision

docs.opencv.org

Open CV 공식 도큐멘테이션 한 곳에 모은 글

https://koreapy.tistory.com/1352

해당 포스팅은 OpenCV에서 제공하는 기능들을 공식 도큐멘테이션을 통해서 태스크에 적절한 내용들을 시각적으로 빠르게 서치하기 위해서 메모용으로 작성했습니다.

다음 공식 도큐멘테이션 및 깃을 통해서 자세한 내용 및 함수에 대해서 확인해주세요!

출처: https://docs.opencv.org/4.x/d9/df8/tutorial_root.html

OpenCV: OpenCV Tutorials

OpenCV  4.5.5-dev Open Source Computer Vision

docs.opencv.org

  • Introduction to OpenCV - build and install OpenCV on your computer
  • The Core Functionality (core module) - basic building blocks of the library
  • Image Processing (imgproc module) - image processing functions
  • Application utils (highgui, imgcodecs, videoio modules) - application utils (GUI, image/video input/output)
  • Camera calibration and 3D reconstruction (calib3d module) - extract 3D world information from 2D images
  • 2D Features framework (feature2d module) - feature detectors, descriptors and matching framework
  • Deep Neural Networks (dnn module) - infer neural networks using built-in dnn module
  • Graph API (gapi module) - graph-based approach to computer vision algorithms building
  • Other tutorials (ml, objdetect, photo, stitching, video) - other modules (ml, objdetect, stitching, video, photo)
  • OpenCV iOS - running OpenCV on an iDevice
  • GPU-Accelerated Computer Vision (cuda module) - utilizing power of video card to run CV algorithms

각 함수에 대한 코드 스니펫과 자료가 있는 한글 docs (추천)

한글 docs(스핑크스) https://opencv-python.readthedocs.io/en/latest/doc/02.videoStart/videoStart.html

영상 다루기 — gramman 0.1 documentation

Camera로 부터 영상 재생 Camera로부터 영상을 읽어, 화면에 보옂기 위해서 아래와 같은 순서로 진행을 합니다. VideoCapture Object를 생성합니다. 변수로는 camera device index나 동영상 파일명을 넘겨줍니

opencv-python.readthedocs.io


1. Introduction to OpenCV - build and install OpenCV on your computer

자세한 설치 가이드 포함

https://docs.opencv.org/4.x/df/d65/tutorial_table_of_content_introduction.html

OpenCV: Introduction to OpenCV

OpenCV  4.5.5-dev Open Source Computer Vision

docs.opencv.org

2. The Core Functionality (core module) - basic building blocks of the library

OpenCV 코어 함수 

  • Mat - The Basic Image Container
  • How to scan images, lookup tables and time measurement with OpenCV
  • Mask operations on matrices
  • Operations with images
  • Adding (blending) two images using OpenCV
  • Changing the contrast and brightness of an image!
  • Discrete Fourier Transform
  • File Input and Output using XML and YAML files
  • How to use the OpenCV parallel_for_ to parallelize your code
  • Vectorizing your code using Universal Intrinsics

3. Image Processing (imgproc module) - image processing functions

이미지 프로세스 및 전처리 모듈 설명

Basic

  • Basic Drawing
  • Random generator and text with OpenCV
  • Smoothing Images
  • Eroding and Dilating
  • More Morphology Transformations
  • Hit-or-Miss
  • Extract horizontal and vertical lines by using morphological operations
  • Image Pyramids
  • Basic Thresholding Operations
  • Thresholding Operations using inRange

Transformations

  • Making your own linear filters!
  • Adding borders to your images
  • Sobel Derivatives
  • Laplace Operator
  • Canny Edge Detector
  • Hough Line Transform
  • Hough Circle Transform
  • Remapping
  • Affine Transformations

Histograms

  • Histogram Equalization
  • Histogram Calculation
  • Histogram Comparison
  • Back Projection
  • Template Matching

Contours

  • Finding contours in your image
  • Convex Hull
  • Creating Bounding boxes and circles for contours
  • Creating Bounding rotated boxes and ellipses for contours
  • Image Moments
  • Point Polygon Test

Others

  • Image Segmentation with Distance Transform and Watershed Algorithm
  • Out-of-focus Deblur Filter
  • Motion Deblur Filter
  • Anisotropic image segmentation by a gradient structure tensor
  • Periodic Noise Removing Filter

OpenCV: Periodic Noise Removing Filter

Prev Tutorial: Anisotropic image segmentation by a gradient structure tensor Original author Karpushin Vladislav Compatibility OpenCV >= 3.0 Goal In this tutorial you will learn: how to remove periodic noise in the Fourier domain Theory NoteThe explanation

docs.opencv.org

OpenCV 형상 인식 - OpenCV hyeongsang insig

3. Application utils (highgui, imgcodecs, videoio modules) - application utils (GUI, image/video input/output)

- GUI 및 이미지나 비디오의 인풋과 아웃풋에 대한 유틸 모듈 설명

  • Adding a Trackbar to our applications!
  • Reading Geospatial Raster files with GDAL
  • Video Input with OpenCV and similarity measurement
  • Creating a video with OpenCV
  • Using Kinect and other OpenNI compatible depth sensors
  • Using Orbbec Astra 3D cameras
  • Using Creative Senz3D and other Intel RealSense SDK compatible depth sensors

4. Camera calibration and 3D reconstruction (calib3d module) - extract 3D world information from 2D images

카메라 캘리브레이션 및 3D 재건(calib3d 모듈)에 대한 설명

- 2D 정보 기반의 3D 정보 추출 등에 대한 내용

  • Create calibration pattern
  • Camera calibration with square chessboard
  • Camera calibration With OpenCV
  • Real Time pose estimation of a textured object
  • Interactive camera calibration application

5. 2D Features framework (feature2d module) - feature detectors, descriptors and matching framework

- 특징을 탐지하고, 설명하고 매칭하는 프레임 워크 설명

  • Harris corner detector
  • Shi-Tomasi corner detector
  • Creating your own corner detector
  • Detecting corners location in subpixels
  • Feature Detection
  • Feature Description
  • Feature Matching with FLANN
  • Features2D + Homography to find a known object
  • Detection of planar objects
  • AKAZE local features matching
  • AKAZE and ORB planar tracking
  • Basic concepts of the homography explained with code

OpenCV: Basic concepts of the homography explained with code

Prev Tutorial: AKAZE and ORB planar tracking Compatibility OpenCV >= 3.0 Introduction This tutorial will demonstrate the basic concepts of the homography with some codes. For detailed explanations about the theory, please refer to a computer vision course

docs.opencv.org

OpenCV 형상 인식 - OpenCV hyeongsang insig

6. Deep Neural Networks (dnn module) - infer neural networks using built-in dnn module

- openCV에서 제공하는 DNN 모듈(딥러닝 모듈)에 대한 내용

- 빌트인된 DNN을 통해 인퍼런스 하는 내용 포함

  • Load Caffe framework models
  • How to enable Halide backend for improve efficiency
  • How to schedule your network for Halide backend
  • How to run deep networks on Android device
  • YOLO DNNs
  • How to run deep networks in browser
  • Custom deep learning layers support
  • How to run custom OCR model
  • High Level API: TextDetectionModel and TextRecognitionModel
  • DNN-based Face Detection And Recognition

PyTorch models with OpenCV

In this section you will find the guides, which describe how to run classification, segmentation and detection PyTorch DNN models with OpenCV.

  • Conversion of PyTorch Classification Models and Launch with OpenCV Python
  • Conversion of PyTorch Classification Models and Launch with OpenCV C++
  • Conversion of PyTorch Segmentation Models and Launch with OpenCV

TensorFlow models with OpenCV

In this section you will find the guides, which describe how to run classification, segmentation and detection TensorFlow DNN models with OpenCV.

  • Conversion of TensorFlow Classification Models and Launch with OpenCV Python
  • Conversion of TensorFlow Detection Models and Launch with OpenCV Python
  • Conversion of TensorFlow Segmentation Models and Launch with OpenCV

7. Graph API (gapi module) - graph-based approach to computer vision algorithms building

- 그래프 베이스의 이미지 접근 및 CV(컴퓨터 비전) 알고리즘 빌딩

In this section you will learn about graph-based image processing and how G-API module can be used for that.

  • Face analytics pipeline with G-APICompatibility: > OpenCV 4.2This tutorial illustrates how to build a hybrid video processing pipeline with G-API where Deep Learning and image processing are combined effectively to maximize the overall throughput. This sample requires Intel® distribution of OpenVINO™ Toolkit version 2019R2 or later.
  • Author: Dmitry Matveev
  • Languages: C++
  • Porting anisotropic image segmentation on G-APICompatibility: > OpenCV 4.0This is an end-to-end tutorial where an existing sample algorithm is ported on G-API, covering the basic intuition behind this transition process, and examining benefits which a graph model brings there.
  • Author: Dmitry Matveev
  • Languages: C++
  • Implementing a face beautification algorithm with G-APICompatibility: > OpenCV 4.2In this tutorial we build a complex hybrid Computer Vision/Deep Learning video processing pipeline with G-API.
  • Author: Orest Chura
  • Languages: C++

8. Other tutorials (ml, objdetect, photo, stitching, video) - other modules (ml, objdetect, stitching, video, photo)

- 머신러닝, 객체 탐지, 스티칭, 비디오, 사진에 대한 듀토리얼들 포함

  • photo. High Dynamic Range Imaging
  • stitching. High level stitching API (Stitcher class)
  • video. How to Use Background Subtraction Methods
  • video. Meanshift and Camshift
  • video. Optical Flow
  • objdetect. Cascade Classifier
  • objdetect. Cascade Classifier Training
  • ml. Introduction to Support Vector Machines
  • ml. Support Vector Machines for Non-Linearly Separable Data
  • ml. Introduction to Principal Component Analysis (PCA)

9. OpenCV iOS - running OpenCV on an iDevice

- IOS에서 OpenCV 튜토리얼 (아이폰 앱 개발용)

  • Installation in iOS
  • OpenCV iOS Hello
  • OpenCV iOS - Image Processing
  • OpenCV iOS - Video Processing

10. GPU-Accelerated Computer Vision (cuda module) - utilizing power of video card to run CV algorithms

- 비디오 카드나 GPU 리소스 활용해서 OpenCV 활용하는 방법 듀토리얼

Squeeze out every little computation power from your system by using the power of your video card to run the OpenCV algorithms.

  • Similarity check (PNSR and SSIM) on the GPUCompatibility: > OpenCV 2.0This will give a good grasp on how to approach coding on the GPU module, once you already know how to handle the other modules. As a test case it will port the similarity methods from the tutorial Video Input with OpenCV and similarity measurement to the GPU.
  • Author: Bernát Gábor
  • Languages: C++
  • Using a cv::cuda::GpuMat with thrustCompatibility: >= OpenCV 3.0
  • This tutorial will show you how to wrap a GpuMat into a thrust iterator in order to be able to use the functions in the thrust library.
  • Languages: C++

Quick 설치

cv2를 설치하기 위해선 다음과 같은 명령어 실행

버전 에러시 파이썬 3.9 말고 파이썬 3.6~3.8중 호환되는 라이브러리들이 많은 버전 사용하면 좋습니다.

pip install opencv-python
import cv2
import cv2 as cv #주로 cv로 많이 사용합니다.

# 설치가 안됐을때 노출되는 에러메시지
>>No module named 'cv2'

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

지금까지 데이터 분석 - 머신러닝 - 딥러닝 - 자연어 처리를 공부했습니다.

이것들에 이어 앞으로는 컴퓨터 비전 공부를 하려고 합니다. 사실 자연어 처리도 얕게 공부한 수준이긴 합니다.

이런 상황에서 새롭게 컴퓨터 비전을 공부하는 이유는 캐글에 컴퓨터 비전 관련 대회가 상당히 많기 때문입니다.

자연어 처리 대회가 많다면 그것에 집중하겠는데 아무래도 비전 대회가 많아 이 분야도 공부를 하고 싶었습니다.

공부를 위해 선정한 책은 파이썬으로 만드는 OpenCV 프로젝트(이세우 저)입니다. 

주변에서 추천받은 책이기도 하고, 쉽게 쓰였다는 후기가 많아서 이 책으로 선정했습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

파이썬으로 만드는 OpenCV 프로젝트

이 책을 완독하고 내용을 완전히 습득하는데 얼마나 걸릴지는 모르겠습니다. 중간에 캐글 대회와 병행한다면 몇 달이 걸릴 수도 있을 겁니다. 그래도 재미있을 것 같습니다. 책을 쭉 훑어보니 굉장히 재밌어 보이는 프로젝트들이 많네요. 앞으로 이 책을 통해 공부한 내용을 정리해서 포스팅하도록 하겠습니다. 분량이 얼마나 될지, 기간이 얼마나 걸릴지 모르겠지만 천천히 진행해보겠습니다.

OpenCV를 활용한 대부분의 작업은 이미지를 읽어서 적절한 연산을 적용한 뒤 결과를 출력하거나 파일로 저장하는 것입니다. 이번 포스팅에서는 OpenCV의 가장 첫 번째 단계인 이미지 입출력에 대해 알아보겠습니다. 이 포스팅은 파이썬으로 만드는 OpenCV 프로젝트(이세우 저)를 정리한 것임을 우선 밝힙니다.

코드: https://github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/02.interface

이미지 읽기

OpenCV를 사용해서 이미지를 화면에 표시하는 가장 간단한 코드는 아래와 같습니다.

이미지 파일은 이곳에서 다운받으시기 바랍니다. 참고로 몇몇 사진(바로 아래 예제 포함)은 제가 직접 찍어서 다운받은 폴더에 없을 겁니다. 가끔 이미지 파일 이름을 바꿔 실행한 것도 있습니다. 파일 이름이 다르다면 결과 이미지를 보고 같은 이미지를 골라주시면 됩니다. 만약 제가 직접 찍은 사진이라 다운받은 폴더에 없다면 아무 사진으로 실습하셔도 무방합니다. 직접 찍은 사진으로 실습하면 더 재미가 있을 겁니다.

# 이미지 파일을 화면에 표시(img_show.py)

import cv2

img_file = "../img/yeosu.jpg" # 표시할 이미지 경로            ---①
img = cv2.imread(img_file)    # 이미지를 읽어서 img 변수에 할당 ---②

if img is not None:
  cv2.imshow('IMG', img)      # 읽은 이미지를 화면에 표시      --- ③
  cv2.waitKey()               # 키가 입력될 때 까지 대기      --- ④
  cv2.destroyAllWindows()     # 창 모두 닫기            --- ⑤
else:
    print('No image file.')
    
OpenCV 형상 인식 - OpenCV hyeongsang insig

코드를 실행하면 멋진 여수 밤바다 사진 새로운 창에 뜹니다. (참고로, 위 사진은 제가 직접 찍었습니다. 직접 찍은 사진를 활용해 코드를 돌려보세요.) cv2.imread() 함수로 이미지를 읽어 올 수 있습니다. cv2.imshow() 함수는 읽어 온 이미지를 화면에 표시해주는 기능을 합니다. 그리고 키보드의 아무 키나 누르면 창이 꺼집니다. 코드에서 cv2.waitKey()가 없으면 창이 떴다가 바로 사라집니다. 키가 입력될 때까지 사진을 뜨게 하는 기능을 cv2.wiatKey()가 하기 때문입니다. 그리고 키가 입력된 후에는 cv2.destroyAllWindows()를 통해 모든 윈도우 창을 끕니다.

이제, 흑백 이미지를 출력해보겠습니다. cv2.imread()의 파라미터로 cv2.IMREAD_GRAYSCALE을 전달하면 흑백 이미지로 불러옵니다. 아래 코드는 위와 동일하고 cv2.IMREAD_GRAYSCALE만 파라미터로 적용해주었습니다.

  • cv2.imread(path, flag)path: 이미지 파일 경로flag: 이미지를 어떻게 읽을지 방식 설정- cv2.IMREAD_COLOR(기본값): 색깔 이미지로 불러옵니다. 이때 투명도(alpha값)는 무시합니다.- cv2.IMREAD_GRAYSCALE: 이미지를 흑백톤으로 불러옵니다.- cv2.IMREAD_UNCHANGED: 투명도(alpha값)를 포함해 이미지를 그대로 불러옵니다.
# 이미지 파일을 회색으로 화면에 표시(img_show_gray.py)

import cv2

img_file = "../img/yeosu.jpg" 
img = cv2.imread(img_file, cv2.IMREAD_GRAYSCALE)    # 회색으로 읽기

if img is not None:
  cv2.imshow('IMG', img)
  cv2.waitKey()
  cv2.destroyAllWindows()
else:
    print('No image file.')
    
OpenCV 형상 인식 - OpenCV hyeongsang insig

위와 같이 회색톤의 사진이 뜨는 걸 볼 수 있습니다. 다음으로 이미지를 저장하는 방법에 대해 알아보겠습니다.

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

이미지 저장하기

위 코드에서 cv2.imread() 함수를 호출하면 읽은 사진 파일이 img라는 변수에 담깁니다. cv2.imwrite() 함수를 호출하면 img 변수에 담긴 사진 파일을 자신의 PC에 저장할 수 있습니다. 아래는 원본 컬러 파일인 yeosu.jpg를 읽어 회색으로 변경한 뒤 회색 사진을 yeosu_gray.jpg로 저장하는 코드입니다.

# 이미지 저장하기 (img_write.py)

import cv2

img_file = '../img/yeosu.jpg'
save_file = '../img/yeosu_gray.jpg'

img = cv2.imread(img_file, cv2.IMREAD_GRAYSCALE)
cv2.imshow(img_file, img)
cv2.imwrite(save_file, img) #파일로 저장, 포맷은 확장에 따름
cv2.waitKey()
cv2.destroyAllWindows()

위 코드를 실행하면 img 디렉토리에 yeosu_gray.jpg라는 회색 사진이 생성된 것을 볼 수 있습니다.

동영상 파일 읽기

OpenCV는 이미지뿐만 아니라 동영상도 처리할 수 있습니다. 아래는 동영상 파일을 읽기 위한 간단한 코드입니다. 아래 코드를 실행하면 짧은 애니메이션 동영상이 실행될 겁니다.

# 동영상 파일 읽기 (video_play.py)

import cv2

video_file = "../img/big_buck.avi" # 동영상 파일 경로

cap = cv2.VideoCapture(video_file) # 동영상 캡쳐 객체 생성  ---①
if cap.isOpened():                 # 캡쳐 객체 초기화 확인
    while True:
        ret, img = cap.read()      # 다음 프레임 읽기      --- ②
        if ret:                     # 프레임 읽기 정상
            cv2.imshow(video_file, img) # 화면에 표시  --- ③
            cv2.waitKey(25)            # 25ms 지연(40fps로 가정)   --- ④
        else:                       # 다음 프레임 읽을 수 없슴,
            break                   # 재생 완료
else:
    print("can't open video.")      # 캡쳐 객체 초기화 실패
cap.release()                       # 캡쳐 자원 반납
cv2.destroyAllWindows()

cv2.VideoCapture(video_file)을 통해 동영상 파일인 video_file의 첫 프레임 읽어 캡처 객체 cap에 저장합니다. 여기서 주의할 점은 동영상 전체가 cap에 담기는 것이 아닙니다. 동영상의 첫 프레임만 담기는 겁니다. cap.isOpened()는 cap 객체가 지정한 파일로 정상적으로 초기화되었는지 확인하는 코드입니다. 초기화가 잘 되었다면 True를 반환하고, 그렇지 않으면 False를 반환합니다. 연속해서 파일의 프레임을 읽어오기 위해 무한루프로 cap.read()를 호출합니다. 프레임을 잘 읽었다면 ret은 True, img는 프레임 이미지가 됩니다. 제대로 읽히지 않았다면 ret은 False, img는 None이 됩니다. 다음 프레임 읽기가 실패하는 경우는 파일이나 장치에 문제가 있거나 파일의 끝에 도달했을 경우입니다. 이제, cv2.imshow(video_file, img)를 호출하여 프레임 이미지를 화면에 표시합니다. imshow의 첫 번째 인자인 video_file은 화면에 나타나는 창의 제목이고, 두 번째 인자인 img는 화면에 표시할 프레임 이미지 객체입니다. 모든 코드가 실행되고 난 뒤에는 cap.release() 함수를 호출해서 자원을 반납해야 합니다.

동영상은 여러 프레임 이미지의 합이라고 생각하시면 위 코드를 이해하기가 쉽습니다. 단편적인 프레임 이미지를 빠른 시간에 보여주면 움직이는 동영상처럼 보이죠? 위 코드도 하나의 프레임 이미지를 빠른 시간에 보여주는 방식으로 동작합니다. cv2.waitKey(25) 코드가 없다면 프레임이 너무 빨리 넘어가 눈으로 볼 수 없습니다. 하지만 25ms의 지연을 주면서 각 프레임을 화면에 표시해주기 때문에 우리는 동영상을 볼 수 있습니다. cv2.waitKey() 안에 들어가는 숫자를 지연 시간이라고 합니다. 25보다 작은 숫자를 넣어주면 화면이 동영상이 빠르게 재생되는 것처럼 보이고, 25보다 큰 숫자를 넣어주면 동영상이 천천히 재생되는 것처럼 보일 것입니다.

카메라(웹캠) 프레임 읽기

이미 저장되어 있는 동영상 파일뿐만 아니라 웹캠으로 라이브 영상을 읽을 수도 있습니다. 만약 사용하시는 노트북이나 PC에 웹캠이 있다면 아래 코드를 실행할 수 있습니다. 아래 코드를 실행하면 웹캠을 통해 자신의 모습이 보이는 창이 하나 뜰 겁니다.

# 카메라(웹캠) 프레임 읽기 (video_cam.py)

import cv2

cap = cv2.VideoCapture(0)               # 0번 카메라 장치 연결 ---①
if cap.isOpened():                      # 캡쳐 객체 연결 확인
    while True:
        ret, img = cap.read()           # 다음 프레임 읽기
        if ret:
            cv2.imshow('camera', img)   # 다음 프레임 이미지 표시
            if cv2.waitKey(1) != -1:    # 1ms 동안 키 입력 대기 ---②
                break                   # 아무 키라도 입력이 있으면 중지
        else:
            print('no frame')
            break
else:
    print("can't open camera.")
cap.release()                           # 자원 반납
cv2.destroyAllWindows()

동영상 프레임을 실행하는 코드와 거의 동일합니다. 다른 점이라고 하면 cv2.VideoCapture(0)과 cv.waitKey(1) != -1입니다. 우선, cv2.VideoCapture()은 인자로 동영상 파일 경로를 입력할 수도 있지만 카메라 장치 번호를 입력할 수도 있습니다. 동영상 파일 경로를 입력하면 해당 동영상의 캡처 객체가 return 되지만 카메라 장치 번호를 입력하면 웹캠과 연결됩니다. 카메라 장치 번호는 0부터 시작합니다. 웹캠이 하나밖에 없다면 인자로 0을 넣으면 됩니다.

동영상 파일을 읽는 것과 다르게 카메라로부터 프레임을 읽는 경우 파일의 끝이 정해져 있지 않아 무한 루프를 빠져나올 조건이 없습니다. 그래서 cv.waitKey(1) != -1 코드에서 사용자가 아무 키나 누르면 break가 되어 루프를 빠져나옵니다. cv2.waitKey() 함수는 지정된 시간 동안 아무 키 입력이 없으면 -1을 반환합니다. 아무 키나 입력을 하면 -1이 반환되지 않기 때문에 break가 되는 것입니다.

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

웹캠으로 사진 찍기

웹캠이나 동영상의 특정 프레임을 이미지로 저장할 수도 있습니다. 특정 프레임을 저장하는 것은 cv2.imwrite() 함수를 활용하면 됩니다. 아래 코드는 웹캠으로 프레임을 화면에 표시하다가 아무 키나 누르면 해당 프레임을 이미지 파일로 저장하는 코드입니다. 카메라로 셀카를 찍는 것과 같다고 보시면 됩니다.

# 웹캠으로 사진찍기 (video_cam_take_pic.py)

import cv2

cap = cv2.VideoCapture(0)                       # 0번 카메라 연결
if cap.isOpened() :
    while True:
        ret, frame = cap.read()                 # 카메라 프레임 읽기
        if ret:
            cv2.imshow('camera',frame)          # 프레임 화면에 표시
            if cv2.waitKey(1) != -1:            # 아무 키나 누르면
                cv2.imwrite('photo.jpg', frame) # 프레임을 'photo.jpg'에 저장
                break
        else:
            print('no frame!')
            break
else:
    print('no camera!')
cap.release()
cv2.destroyAllWindows()

위 코드를 실행하면 웹캠이 실행되어 자신의 모습이 화면에 나올 것입니다. 아무 키나 누르면 그 순간의 프레임이 캡처되어 이미지로 저장됩니다.

웹캠으로 녹화하기

셀카를 찍는 것처럼 웹캠으로 하나의 프레임을 이미지로 저장할 수 있다는 것을 배웠습니다. cv2.VideoWriter() 함수를 쓰면 여러 프레임을 동영상으로 저장할 수도 있습니다.

cv2.VideoWriter(file_path, fourcc, fps, (width, height)) 에서 file_path는 동영상 파일을 저장할 경로, fourcc는 동영상 인코딩 형식(codec 정보), fps는 초당 저장될 프레임 수, (width, height)는 프레임의 너비와 높이를 뜻합니다. 아래 코드를 실행한 뒤 아무 키나 누르면 키를 누르기 전까지의 모든 프레임이 record.avi라는 동영상으로 저장됩니다.

# 웹캠으로 녹화하기 (video_cam_rec.py)

import cv2

cap = cv2.VideoCapture(0)    # 0번 카메라 연결
if cap.isOpened:
    file_path = './record.avi'    # 저장할 파일 경로 이름 ---①
    fps = 30.0                     # FPS, 초당 프레임 수
    fourcc = cv2.VideoWriter_fourcc(*'DIVX') # 인코딩 포맷 문자
    width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
    height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
    size = (int(width), int(height))                        # 프레임 크기
    out = cv2.VideoWriter(file_path, fourcc, fps, size) # VideoWriter 객체 생성
    while True:
        ret, frame = cap.read()
        if ret:
            cv2.imshow('camera-recording',frame)
            out.write(frame)                        # 파일 저장
            if cv2.waitKey(int(1000/fps)) != -1: 
                break
        else:
            print("no frame!")
            break
    out.release()                                   # 파일 닫기
else:
    print("can't open camera!")
cap.release()
cv2.destroyAllWindows()

cv2.VideoWriter() 객체를 생성하여 out 변수에 저장했습니다. out.write(frame)을 호출하면 현재 frame이 저장됩니다.

cap.get()은 동영상이나 카메라의 속성을 확인하는 함수입니다. cv2.CAP_PROP_FRAME_WIDTH는 프레임 너비, cv2.CAP_PROP_FRAME_HEIGHT는 프레임 높이를 뜻합니다. 따라서 cap.get(cv2.CAP_PROP_FRAME_WIDTH)은 cap 객체의 프레임 너비를 반환합니다. 

참고로, FPS(Frames Per Second)는 초당 프레임 수를 뜻하며 지연 시간은 FPS를 활용하여 구할 수 있습니다.

지연시간 = 1000 / fps

1000으로 계산하는 이유는 1초(1s)가 1,000밀리 초(1,000ms)이기 때문입니다. 따라서 코드에서 지연 시간을 cv2.waitKey(int(1000/fps))로 설정한 것입니다.

이번 포스팅에서는 이미지에 도형을 그리는 방법에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: https://github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/02.interface

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

직선 그리기

우선, 아래와 같이 빈 도화지 같은 하얀 이미지를 준비합니다. (blank_500.jpg) 이곳에 다양한 직선을 그려보겠습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

blank_500.jpg

cv2.line(img, start, end, color, thickness, lineType) 함수를 호출하여 다양한 선을 그릴 수 있습니다. 파라미터는 아래와 같습니다.

img: 그림을 그릴 이미지 파일
start: 선 시작 좌표(ex; (0,0))
end: 선 종료 좌표(ex; (500. 500))
color: BGR형태의 선 색상 (ex; (255, 0, 0) -> Blue)
thickness (int): 선의 두께. pixel (default=1)
lineType: 선 그리기 형식 (cv2.LINE_4, cv2.LINE_8, cv2.LINE_AA)

주의할 점은 color가 RGB 형태가 아니라 BGR 형태입니다. 순서가 반대임을 유의해야 합니다. 아래의 코드를 실행하면 빈 이미지인 blank_500.jpg에 다양한 직선을 그립니다.

# 다양한 직선 그리기(draw_line.py)

import cv2

img = cv2.imread('../img/blank_500.jpg')

cv2.line(img, (50, 50), (150, 50), (255,0,0))   # 파란색 1픽셀 선
cv2.line(img, (200, 50), (300, 50), (0,255,0))  # 초록색 1픽셀 선
cv2.line(img, (350, 50), (450, 50), (0,0,255))  # 빨간색 1픽셀 선

# 하늘색(파랑+초록) 10픽셀 선      
cv2.line(img, (100, 100), (400, 100), (255,255,0), 10)          
# 분홍(파랑+빨강) 10픽셀 선      
cv2.line(img, (100, 150), (400, 150), (255,0,255), 10)          
# 노랑(초록+빨강) 10픽셀 선      
cv2.line(img, (100, 200), (400, 200), (0,255,255), 10)          
# 회색(파랑+초록+빨강) 10픽셀 선  
cv2.line(img, (100, 250), (400, 250), (200,200,200), 10)        
# 검정 10픽셀 선    
cv2.line(img, (100, 300), (400, 300), (0,0,0), 10)                    

# 4연결 선
cv2.line(img, (100, 350), (400, 400), (0,0,255), 20, cv2.LINE_4)   
# 8연결 선
cv2.line(img, (100, 400), (400, 450), (0,0,255), 20, cv2.LINE_8)    
# 안티에일리어싱 선 
cv2.line(img, (100, 450), (400, 500), (0,0,255), 20, cv2.LINE_AA)   
# 이미지 전체에 대각선 
cv2.line(img, (0,0), (500,500), (0,0,255))                      

cv2.imshow('lines', img)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

cv2.LINE_4와 cv2.LINE_8 파라미터를 전달하면 픽셀이 깨져 보입니다. (맨 아래에서 두 번째, 세 번째 빨간 줄) cv2.LINE_AA는 픽셀이 깨져서 발생하는 계단 현상을 최소화하는 연결 선을 그려줍니다. (맨 아래 빨간 줄)

사각형 그리기

사각형은 cv2.rectangle(img, start, end, color, thickness, lineType) 함수를 호출하여 그릴 수 있습니다. 파라미터는 아래와 같습니다.

img: 그림을 그릴 이미지 파일 
start: 사각형 시작 꼭짓점 좌표(ex; (0,0)) 
end: 사각형 종료 꼭짓점 좌표(ex; (500. 500)) 
color: BGR형태의 선 색상 (ex; (255, 0, 0) -> Blue) 
thickness (int): 선의 두께. pixel (default=1,  사각형 전체를 색상으로 채우기=-1)
lineType: 선 그리기 형식 (cv2.LINE_4, cv2.LINE_8, cv2.LINE_AA)

# 사각형 그리기(draw_rect.py)

import cv2

img = cv2.imread('../img/blank_500.jpg')

# 좌상, 우하 좌표로 사각형 그리기, 선 두께는 default 1
cv2.rectangle(img, (50, 50), (150, 150), (255,0,0) )        
# 우하, 좌상 좌표로 사각형 그리기, 선 두께 10
cv2.rectangle(img, (300, 300), (100, 100), (0,255,0), 10 )  
# 우상, 좌하 좌표로 사각형 채워 그리기 ---①
cv2.rectangle(img, (450, 200), (200, 450), (0,0,255), -1 )  

cv2.imshow('rectangle', img)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

다각형 그리기

다각형은 cv2.polylines(img, pts, isClosed, color, thickness, lineType) 함수를 호출하여 그릴 수 있습니다.

img: 그림을 그릴 이미지 파일 
pts: 연결할 꼭짓점 좌표, Numpy array
isClosed: 닫힌 도형 여부, True/False
color: BGR형태의 선 색상 (ex; (255, 0, 0) -> Blue) 
thickness (int): 선의 두께. pixel (default=1)
lineType: 선 그리기 형식 (cv2.LINE_4, cv2.LINE_8, cv2.LINE_AA)

isClosed 매개변수는 닫힌 도형을 그릴지, 열린 도형을 그릴지 여부를 결정합니다. True일 경우 닫힌 도형을 그리기 때문에 첫 꼭짓점과 마지막 꼭짓점을 서로 연결합니다. 반면 False일 경우 열린 도형을 그리기 때문에 첫 꼭짓점과 마지막 꼭짓점을 서로 연결하지 않습니다.

# 다각형 그리기(draw_poly)

import cv2
import numpy as np                          # 좌표 표현을 위한 numpy 모듈  ---①

img = cv2.imread('../img/blank_500.jpg')

# Numpy array로 좌표 생성 ---②
# 번개 모양 선 좌표
pts1 = np.array([[50,50], [150,150], [100,140],[200,240]], dtype=np.int32) 
# 삼각형 좌표
pts2 = np.array([[350,50], [250,200], [450,200]], dtype=np.int32) 
# 삼각형 좌표
pts3 = np.array([[150,300], [50,450], [250,450]], dtype=np.int32) 
# 5각형 좌표
pts4 = np.array([[350,250], [450,350], [400,450], [300,450], [250,350]],\
                 dtype=np.int32) 

# 다각형 그리기 ---③
cv2.polylines(img, [pts1], False, (255,0,0))       # 번개 모양 선 그리기
cv2.polylines(img, [pts2], False, (0,0,0), 10)     # 3각형 열린 선 그리기 ---④
cv2.polylines(img, [pts3], True, (0,0,255), 10)    # 3각형 닫힌 도형 그리기 ---⑤
cv2.polylines(img, [pts4], True, (0,0,0))          # 5각형 닫힌 도형 그리기

cv2.imshow('polyline', img)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

원, 타원, 호 그리기

cv2.circle(img, center, radius, color, thickness, lineType) 함수는 원을 그려주고, 
cv2.ellipse(img, center, axes, angle, startAngle, endAngle, color, thickness, lineType) 함수는 타원을 그려줍니다.

cv2.circle() 함수의 매개변수는 직관적입니다. center는 원의 중심 좌표(x, y)이며, radius는 원의 반지름입니다. color는 색상, thickness는 두께, lineType은 선 타입입니다. cv2.ellipse() 함수의 매개변수가 좀 헷갈릴 수 있습니다. 우선, img, color, thickness, lineType은 cv2.line()과 동일합니다. 그 이외의 매개변수는 아래와 같습니다.

center: 타원의 중심 좌표 (x, y)
axes: 타원의 중심에서 가장 긴 축의 길이와 가장 짧은 축의 길이
angle: 타원의 기준 축 회전 각도
startAngle: 타원의 호가 시작하는 각도
endAngle: 타원의 호가 끝나는 각도

# 원, 타원, 호 그리기(draw_circle.py)

import cv2

img = cv2.imread('../img/blank_500.jpg')

# 원점(150,150), 반지름 100 ---①
cv2.circle(img, (150, 150), 100, (255,0,0))     
# 원점(300,150), 반지름 70 ---②
cv2.circle(img, (300, 150), 70, (0,255,0), 5)   
# 원점(400,150), 반지름 50, 채우기 ---③
cv2.circle(img, (400, 150), 50, (0,0,255), -1)  

# 원점(50,300), 반지름(50), 회전 0, 0도 부터 360도 그리기 ---④
cv2.ellipse(img, (50, 300), (50, 50), 0, 0, 360, (0,0,255))    
# 원점(150, 300), 아래 반원 그리기 ---⑤
cv2.ellipse(img, (150, 300), (50, 50), 0, 0, 180, (255,0,0))    
#원점(200, 300), 윗 반원 그리기 ---⑥
cv2.ellipse(img, (200, 300), (50, 50), 0, 181, 360, (0,0,255))    

# 원점(325, 300), 반지름(75,50) 납작한 타원 그리기 ---⑦
cv2.ellipse(img, (325, 300), (75, 50), 0, 0, 360, (0,255,0))    
# 원점(450,300), 반지름(50,75) 홀쭉한 타원 그리기 ---⑧
cv2.ellipse(img, (450, 300), (50, 75), 0, 0, 360, (255,0,255))    

# 원점(50, 425), 반지름(50,75), 회전 15도 ---⑨
cv2.ellipse(img, (50, 425), (50, 75), 15, 0, 360, (0,0,0))    
# 원점(200,425), 반지름(50,75), 회전 45도 ---⑩
cv2.ellipse(img, (200, 425), (50, 75), 45, 0, 360, (0,0,0))    

# 원점(350,425), 홀쭉한 타원 45도 회전 후 아랫 반원 그리기 ---⑪
cv2.ellipse(img, (350, 425), (50, 75), 45, 0, 180, (0,0,255))    
# 원점(400,425), 홀쭉한 타원 45도 회전 후 윗 반원 그리기 ---⑫
cv2.ellipse(img, (400, 425), (50, 75), 45, 181, 360, (255,0,0))    

cv2.imshow('circle', img)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

글 쓰기

cv2.putText(img, text, org, font, fontScale, color, thickness, lineType) 함수는 이미지에 문자열을 표시합니다. 

매개변수 중 text는 표시할 문자열이며, org는 문자열을 표시할 위치(좌측 하단 기준) (x, y)이며, font는 글꼴(cv2.FONT_XXXX 형식)이며, fontScale은 글꼴 크기입니다.

아래 코드는 다양한 글꼴, 크기, 위치를 갖는 글씨를 그려줍니다. 맨 아래 부분의 코드는 글꼴과 이탤릭체를 동시에 적용하는 방법을 보여줍니다.

# 글 쓰기 (draw_text.py)

import cv2

img = cv2.imread('../img/blank_500.jpg')

# sans-serif small
cv2.putText(img, "Plain", (50, 30), cv2.FONT_HERSHEY_PLAIN, 1, (0, 0,0))            
# sans-serif normal
cv2.putText(img, "Simplex", (50, 70), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0,0))        
# sans-serif bold
cv2.putText(img, "Duplex", (50, 110), cv2.FONT_HERSHEY_DUPLEX, 1, (0, 0,0))         
# sans-serif normall X2  ---①
cv2.putText(img, "Simplex", (200, 110), cv2.FONT_HERSHEY_SIMPLEX, 2, (0,0,250)) 

# serif small
cv2.putText(img, "Complex Small", (50, 180), cv2.FONT_HERSHEY_COMPLEX_SMALL, \
            1, (0, 0,0))   
# serif normal
cv2.putText(img, "Complex", (50, 220), cv2.FONT_HERSHEY_COMPLEX, 1, (0, 0,0))
# serif bold
cv2.putText(img, "Triplex", (50, 260), cv2.FONT_HERSHEY_TRIPLEX, 1, (0, 0,0))               
# serif normal X2  ---②
cv2.putText(img, "Complex", (200, 260), cv2.FONT_HERSHEY_TRIPLEX, 2, (0,0,255))               

# hand-wringing sans-serif
cv2.putText(img, "Script Simplex", (50, 330), cv2.FONT_HERSHEY_SCRIPT_SIMPLEX, \
            1, (0, 0,0)) 
# hand-wringing serif
cv2.putText(img, "Script Complex", (50, 370), cv2.FONT_HERSHEY_SCRIPT_COMPLEX, \
            1, (0, 0,0)) 

# sans-serif + italic ---③
cv2.putText(img, "Plain Italic", (50, 430), \
            cv2.FONT_HERSHEY_PLAIN | cv2.FONT_ITALIC, 1, (0, 0,0)) 
# sarif + italic
cv2.putText(img, "Complex Italic", (50, 470), \
            cv2.FONT_HERSHEY_COMPLEX | cv2.FONT_ITALIC, 1, (0, 0,0)) 

cv2.imshow('draw text', img)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

이번 포스팅에서는 창을 관리하는 방법과 키보드 및 마우스 이벤트를 처리하는 방법을 알아보겠습니다. 이번 글 역시 파이썬으로 만드는 OpenCV 프로젝트(이세우 저)를 정리한 것입니다.

코드: https://github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/02.interface

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

창 관리

우선, 창 관리를 하는 5가지 함수에 대해 알아보겠습니다.

cv2.namedWindow(winname, flags) 함수는 winname이라는 이름을 갖는 창을 생성해줍니다. 파라미터는 아래와 같습니다.

winname: 창 구분자로 활용될 창 이름
flags: 창 옵션 (cv2.WINDOW_NORMAL: 사용자가 창 크기를 조정할 수 있음, cv2.WINDOW_AUTOSIZE: 이미지와 동일한 크기로 창 크기를 재조정할 수 없음)

cv2.moveWindow(winname, x, y) 함수를 호출하면 원하는 위치로 창을 옮길 수 있습니다.

winname: 위치를 변경할 창 이름
x, y: 변경할 위치 (x, y 좌표)

cv2.resizeWindow(winname, width, hegith) 함수는 winname 창의 크기를 (width, height) 크기로 변경해줍니다.

cv2.destroyWindow(winname) 함수를 호출하면 winname에 해당하는 창을 닫습니다.

cv2.destroyAllwindows() 함수는 열린 모든 창을 닫습니다.

아래 예시는 위 5개의 함수를 활용하는 코드입니다. 'origin', 'gray'라는 창을 생성하여 위치를 옮기고 크기를 조정하고 창을 닫는 것을 보여줍니다. 주석을 참고해주시기 바랍니다.

# 창 관리 (win.py)

import cv2

file_path = '../img/yeosu.jpg'
img = cv2.imread(file_path)                            # 이미지를 기본 값으로 읽기
img_gray = cv2.imread(file_path, cv2.IMREAD_GRAYSCALE) # 이미지를 그레이 스케일로 읽기

cv2.namedWindow('origin')                               # origin 이름으로 창 생성
cv2.namedWindow('gray', cv2.WINDOW_NORMAL)              # gray 이름으로 창 생성
cv2.imshow('origin', img)                               # origin 창에 이미지 표시
cv2.imshow('gray', img_gray)                            # gray 창에 이미지 표시

cv2.moveWindow('origin', 0, 0)                          # 창 위치 변경
cv2.moveWindow('gray', 100, 100)                        # 창 위치 변경

cv2.waitKey(0)                                          # 아무키나 누르면
cv2.resizeWindow('origin', 200, 200)                    # 창 크기 변경 (변경 안됨)
cv2.resizeWindow('gray', 100, 100)                      # 창 크기 변경 (변경 됨))

cv2.waitKey(0)                                          # 아무키나 누르면
cv2.destroyWindow("gray")                               # gray 창 닫기

cv2.waitKey(0)                                          # 아무키나 누르면
cv2.destroyAllWindows()                                 # 모든 창 닫기
OpenCV 형상 인식 - OpenCV hyeongsang insig

키보드 이벤트 처리

이제 키보드 이벤트를 처리하는 방법에 대해 살펴보겠습니다. 앞서 이미 사용했던 cv2.waitKey(delay) 함수는 delay 밀리초만큼 프로그램을 멈추고 있다가 키보드의 눌린 키에 대응하는 값을 반환합니다. dalay 시간만큼 키보드 입력이 없다면 -1을 반환합니다. delay의 default값은 0인데, 이 경우 키보드 입력이 있을 때까지 영원히 대기합니다. 

아래 코드는 화면에 여수 밤바다 이미지를 나타내고, 키보드의 h, j, k, l 키를 누르면 이미지가 왼쪽, 위쪽, 아래쪽, 오른쪽으로 10픽셀씩 이동합니다. esc나 q를 누르면 창이 종료됩니다.

# 키 이벤트 처리 (event_key.py)

import cv2

img_file = "../img/yeosu.jpg" 
img = cv2.imread(img_file) 
title = 'IMG'                   # 창 이름 
x, y = 100, 100                 # 최초 좌표

while True:
    cv2.imshow(title, img)
    cv2.moveWindow(title, x, y)
    key = cv2.waitKey(0) & 0xFF # 키보드 입력을 무한 대기, 8비트 마스크처리
    print(key, chr(key))        # 키보드 입력 값,  문자 값 출력
    if key == ord('h'):         # 'h' 키 이면 좌로 이동
        x -= 10
    elif key == ord('j'):       # 'j' 키 이면 아래로 이동
        y += 10
    elif key == ord('k'):       # 'k' 키 이면 위로 이동
        y -= 10
    elif key == ord('l'):       # 'l' 키 이면 오른쪽으로 이동
        x += 10
    elif key == ord('q') or key == 27: # 'q' 이거나 'esc' 이면 종료
        break
        cv2.destroyAllWindows()
    cv2.moveWindow(title, x, y )   # 새로운 좌표로 창 이동
        

마우스 이벤트 처리

마우스 이벤트는 cv2.setMouseCallback(windowName, onMouse, param=None) 함수로 처리할 수 있습니다. 파라미터는 다음과 같습니다.

windowName: 이벤트를 등록할 윈도우 이름
onMouse: 이벤트 처리를 위해 미리 선언해 놓은 마우스 콜백 함수

콜백 함수인 onMouse(evnet, x, y, flags, param) 함수는 마우스의 이벤트와 마우스 좌표를 처리합니다. 여기서 event에는 마우스의 움직임, 왼쪽 버튼 누름, 왼쪽 버튼 뗌, 오른쪽 버튼 누름, 오른쪽 버튼 뗌, 왼쪽 버튼 더블 클릭, 휠 스크롤 등이 있습니다. cv2.EVENT_로 시작하는 12가지 이벤트가 있습니다. (ex. cv2.EVENT_MOSEMOVE: 마우스 움직임, cv2.EVENT_LBUTTONDOWN: 왼쪽 버튼 누름) flags는 컨트롤, 쉬프트, 알트와 같은 키를 함께 누른 상태처럼 이벤트를 처리하게 합니다. flags와 param을 사용하지 않는다 하더라도 콜백 함수 선언부에 flags와 param을 기재해야 합니다. 그렇지 않으면 오류가 발생합니다. 

아래 코드를 실행한 뒤 마우스 클릭을 하면 화면에 까만 원을 그릴 수 있습니다.

# 마우스 이벤트로 원 그리기 (event_mouse_circle.py)

import cv2

title = 'mouse event'                   # 창 제목
img = cv2.imread('../img/blank_500.jpg') # 백색 이미지 읽기
cv2.imshow(title, img)                  # 백색 이미지 표시

def onMouse(event, x, y, flags, param): # 아무스 콜백 함수 구현 ---①
    print(event, x, y, )                # 파라미터 출력
    if event == cv2.EVENT_LBUTTONDOWN:  # 왼쪽 버튼 누름인 경우 ---②
        cv2.circle(img, (x,y), 30, (0,0,0), -1) # 지름 30 크기의 검은색 원을 해당 좌표에 그림
        cv2.imshow(title, img)          # 그려진 이미지를 다시 표시 ---③

cv2.setMouseCallback(title, onMouse)    # 마우스 콜백 함수를 GUI 윈도우에 등록 ---④

while True:
    if cv2.waitKey(0) & 0xFF == 27:     # esc로 종료
        break
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

아래 코드를 통해 flags를 활용해보겠습니다. flag에 전달되는 인자는 다음과 같습니다.

cv2.EVENT_FLAG_CTRLKEY(8): Ctrl 키를 누름
cv2.EVENT_FLAG_SHIFTKEY(16): Shift 키를 누름
cv2.EVENT_FLAG_ALTKEY(32): Alt 키를 누름

마우스 클릭만 했을 때는 위 코드처럼 검정 원이 그려집니다. 컨트롤과 쉬프트 키를 동시에 누르고 마우스 클릭을 하면 초록색 원이, 컨트롤 키만 누르고 마우스 클릭을 하면 빨간색 원이, 쉬프트 키만 누르고 마우스 클릭을 하면 파란색 원이 그려집니다.

# flags를 활용하여 원 그리기 (event_mouse_circle_flag.py)

import cv2

title = 'mouse event'                   # 창 제목
img = cv2.imread('../img/blank_500.jpg') # 백색 이미지 읽기
cv2.imshow(title, img)                  # 백색 이미지 표시

colors = {'black':(0,0,0),
         'red' : (0,0,255),
         'blue':(255,0,0),
         'green': (0,255,0) } # 색상 미리 정의

def onMouse(event, x, y, flags, param): # 아무스 콜백 함수 구현 ---①
    print(event, x, y, flags)                # 파라미터 출력
    color = colors['black']
    if event == cv2.EVENT_LBUTTONDOWN:  # 왼쪽 버튼 누름인 경우 ---②
        # 컨트롤키와 쉬프트 키를 모두 누른 경우
        if flags & cv2.EVENT_FLAG_CTRLKEY and flags & cv2.EVENT_FLAG_SHIFTKEY : 
            color = colors['green']
        elif flags & cv2.EVENT_FLAG_SHIFTKEY : # 쉬프트 키를 누른 경우
            color = colors['blue']
        elif flags & cv2.EVENT_FLAG_CTRLKEY : # 컨트롤 키를 누른 경우
            color = colors['red']
        # 지름 30 크기의 검은색 원을 해당 좌표에 그림
        cv2.circle(img, (x,y), 30, color, -1) 
        cv2.imshow(title, img)          # 그려진 이미지를 다시 표시 ---③

cv2.setMouseCallback(title, onMouse)    # 마우스 콜백 함수를 GUI 윈도우에 등록 ---④

while True:
    if cv2.waitKey(0) & 0xFF == 27:     # esc로 종료
        break
cv2.destroyAllWindows()

참고로, 컨트롤을 누르면 flags & cv2.EVENT_FLAG_CTRLKEY가 True를 반환합니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

이상으로 창관리 및 마우스, 키보드 이벤트 처리 방법에 대해 알아봤습니다.

이번 포스팅에서는 이미지 내에서 관심 영역(Region of Interest, ROI)을 표시하는 방법에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: https://github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/02.interface

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

관심 영역(ROI) 표시하기

관심 영역(ROI)이란 말 그대로 영상 내에서 관심이 있는 영역을 뜻합니다. 아래는 일몰 사진입니다. 일몰 사진 내에서 원하는 영역을 표시하는 방법에 대해 살펴보겠습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

아래 코드를 실행하면 태양 주위를 초록색 사각형으로 표시한 이미지가 생성됩니다.

# 관심영역 표시 (roi.py)

import cv2
import numpy as np

img = cv2.imread('./img/sunset.jpg')

x=320; y=150; w=50; h=50        # roi 좌표
roi = img[y:y+h, x:x+w]         # roi 지정        ---①

print(roi.shape)                # roi shape, (50,50,3)
cv2.rectangle(roi, (0,0), (h-1, w-1), (0,255,0)) # roi 전체에 사각형 그리기 ---②
cv2.imshow("img", img)

key = cv2.waitKey(0)
print(key)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

cv2.imread() 함수를 실행하면 이미지를 numpy 배열로 반환합니다. numpy 배열은 슬라이싱(slicing)이 가능합니다. 원하는 영역을 지정하기 위해서는 이미지 numpy 배열을 슬라이싱 하면 됩니다. 위 코드에서 img[y:y+h, x:x+w]는 원하는 영역을 슬라이싱 합니다. 즉, roi 변수에는 관심 영역인 태양을 슬라이싱 한 numpy 배열이 담깁니다.

cv2.rectangle(roi, (0,0), (h-1, w-1), (0,255,0))은 태양 부분만 슬라이싱 한 roi 이미지의 (0, 0)부터 (h-1, w-1)까지 (0, 255, 0) 색으로 사각형을 표시하는 코드입니다. (0, 0)은 좌측 상단이며, (h-1, w-1)은 우측 하단입니다. roi 이미지의 좌측 상단부터 우측 하단까지를 꼭짓점으로 갖는 사각형을 그려준다는 것입니다. RGB 값인 (0, 255, 0)은 녹색을 나타냅니다.

관심 영역인 roi를 따로 지정하지 않았다면 이 코드는 다음과 같게 됩니다.

cv2.rectangle(roi, (x, y), (x+w, y+h), (0,255,0))

다음 코드는 지정한 관심 영역을 원본 이미지에 복제해서 두 개로 보이게 하거나, 지정한 관심 영역만 새 창에 뜨게 합니다.

# 관심영역 복제 및 새 창에 띄우기 (roi_copy.py)

import cv2
import numpy as np

img = cv2.imread('../img/sunset.jpg')

x=320; y=150; w=50; h=50
roi = img[y:y+h, x:x+w]     # roi 지정
img2 = roi.copy()           # roi 배열 복제 ---①

img[y:y+h, x+w:x+w+w] = roi # 새로운 좌표에 roi 추가, 태양 2개 만들기
cv2.rectangle(img, (x,y), (x+w+w, y+h), (0,255,0)) # 2개의 태양 영역에 사각형 표시

cv2.imshow("img", img)      # 원본 이미지 출력
cv2.imshow("roi", img2)     # roi 만 따로 출력

cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

원본 이미지에 태양이 하나 더 복제가 되어 두 개로 표시가 되었고, 좌측 상단에 새로운 창으로도 표시가 되었습니다.

마우스 드래그로 관심 영역 표시하기

관심 영역을 관심 영역을 표시하기 위해서는 원하는 영역의 좌표와 크기(높이, 너비)가 필요합니다. 하지만 이를 매번 수치로 입력하기는 여간 번거로운 게 아닙니다. 마우스로 관심 영역을 드래그해서 표시한다면 더 편리하겠죠? 아래 코드는 관심 영역을 마우스로 드래그하여 지정하고, 관심 영역만 새 창에 띄우며 파일로 저장하는 예제입니다.

# 마우스로 관심영역 지정 및 표시, 저장 (roi_crop_mouse.py)

import cv2
import numpy as np

isDragging = False                      # 마우스 드래그 상태 저장 
x0, y0, w, h = -1,-1,-1,-1              # 영역 선택 좌표 저장
blue, red = (255,0,0),(0,0,255)         # 색상 값 

def onMouse(event,x,y,flags,param):     # 마우스 이벤트 핸들 함수  ---①
    global isDragging, x0, y0, img      # 전역변수 참조
    if event == cv2.EVENT_LBUTTONDOWN:  # 왼쪽 마우스 버튼 다운, 드래그 시작 ---②
        isDragging = True
        x0 = x
        y0 = y
    elif event == cv2.EVENT_MOUSEMOVE:  # 마우스 움직임 ---③
        if isDragging:                  # 드래그 진행 중
            img_draw = img.copy()       # 사각형 그림 표현을 위한 이미지 복제
            cv2.rectangle(img_draw, (x0, y0), (x, y), blue, 2) # 드래그 진행 영역 표시
            cv2.imshow('img', img_draw) # 사각형 표시된 그림 화면 출력
    elif event == cv2.EVENT_LBUTTONUP:  # 왼쪽 마우스 버튼 업 ---④
        if isDragging:                  # 드래그 중지
            isDragging = False          
            w = x - x0                  # 드래그 영역 폭 계산
            h = y - y0                  # 드래그 영역 높이 계산
            print("x:%d, y:%d, w:%d, h:%d" % (x0, y0, w, h))
            if w > 0 and h > 0:         # 폭과 높이가 양수이면 드래그 방향이 옳음 ---⑤
                img_draw = img.copy()   # 선택 영역에 사각형 그림을 표시할 이미지 복제
                # 선택 영역에 빨간 사각형 표시
                cv2.rectangle(img_draw, (x0, y0), (x, y), red, 2) 
                cv2.imshow('img', img_draw) # 빨간 사각형 그려진 이미지 화면 출력
                roi = img[y0:y0+h, x0:x0+w] # 원본 이미지에서 선택 영영만 ROI로 지정 ---⑥
                cv2.imshow('cropped', roi)  # ROI 지정 영역을 새창으로 표시
                cv2.moveWindow('cropped', 0, 0) # 새창을 화면 좌측 상단에 이동
                cv2.imwrite('./cropped.jpg', roi)   # ROI 영역만 파일로 저장 ---⑦
                print("croped.")
            else:
                cv2.imshow('img', img)  # 드래그 방향이 잘못된 경우 사각형 그림ㅇㅣ 없는 원본 이미지 출력
                print("좌측 상단에서 우측 하단으로 영역을 드래그 하세요.")

img = cv2.imread('../img/sunset.jpg')
cv2.imshow('img', img)
cv2.setMouseCallback('img', onMouse) # 마우스 이벤트 등록 ---⑧
cv2.waitKey()
cv2.destroyAllWindows()

위 코드에서는 onMouse()라는 콜백 함수를 선언했습니다. 이 콜백 함수에서는 세 가지 경우에 따라 이벤트를 분기합니다. (1) 마우스 왼쪽 버튼을 눌렀을 때, (2) 누른 상태로 드래그했을 때, (3) 마우스 왼쪽 버튼을 뗄 때입니다. 마우스 왼쪽 버튼을 눌렀을 때는 드래그가 시작되었다는 것을 기억하기 위한 isDragging이라는 변수의 상태를 True로 변경하고, 드래그가 시작된 점을 x0, y0로 지정합니다. 마우스를 누른 상태로 드래그를 할 때는 img를 복제하고, 복제한 이미지의 (x0, y0)부터 (x, y)까지 파란색 사각형으로 표시합니다. (x0, y0)는 마우스 드래그가 시작된 위치이고, (x, y)는 마우스의 현재 위치입니다. 마지막으로 마우스 왼쪽 버튼을 떼면 isDragging은 False로 바꾸고, 드래그 영역의 폭은 w, 높이는 h로 계산합니다. w와 h가 모두 양수이면 지정 영역을 빨간색 사각형으로 표시하고, 새 창으로도 띄웁니다. 추가로 지정 영역의 이미지를 파일로도 저장해줍니다. 코드 실행 결과는 다음과 같습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

하지만 마우스로 관심 영역을 지정하고 싶을 때 매번 이렇게 코드를 짜는 것도 상당히 번거롭습니다. OpenCV에서는 이를 간단하게 도와주는 함수를 제공합니다. 이 함수를 사용하면 마우스 이벤트 처리를 위한 코드 없이도 관심 영역을 지정할 수 있습니다.

  • ret = cv2.selectROI(win_name, img, showCrossHair=True, fromCenter=False)
    win_name: 관심영역을 표시할 창의 이름
    img: 관심영역을 표시할 이미지
    showCrossHair: 선택 영역 중심에 십자 모양 표시 여부
    fromCenter: 마우스 시작 지점을 영역의 중심으로 지정
    ret: 선택한 영역의 좌표와 크기 (x, y, w, h); 선택을 취소하면 모두 0으로 지정됨

아래 코드는 cv2.selectROI() 함수를 이용하여 선택 영역을 표시합니다. 마우스로 관심 영역을 드래그 한 뒤 스페이스나 엔터를 누르면 새 창에 관심 영역이 뜹니다. 드래그 한 뒤 'c'를 누르면 취소가 됩니다.

# selectROI로 관심영역 지정 및 표시, 저장 (roi_select_img.py)

import cv2, numpy as np

img = cv2.imread('../img/sunset.jpg')

x,y,w,h	= cv2.selectROI('img', img, False)
if w and h:
    roi = img[y:y+h, x:x+w]
    cv2.imshow('cropped', roi)  # ROI 지정 영역을 새창으로 표시
    cv2.moveWindow('cropped', 0, 0) # 새창을 화면 좌측 상단에 이동
    cv2.imwrite('./cropped2.jpg', roi)   # ROI 영역만 파일로 저장

cv2.waitKey(0)
cv2.destroyAllWindows()

위 코드에서 관심영역을 새 창으로 띄워주는 코드 cv2.imshow('cropped', roi)는 있는데, 원본 이미지인 img를 띄워주는 코드는 없습니다. 그러나 코드를 실행하면 원본 이미지도 같이 뜹니다. 이는 cv2.selectROI('img', img, False)가 해주는 것입니다. cv2.selectROI() 함수를 호출하면 원본 이미지가 뜨고, 마우스 이벤트 처리도 도와줍니다.

이번 포스팅에서는 OpenCV로 색상을 표현하는 방식에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/04.img_processing

BGR, BGRA

색상을 표현하는 방법으로는 RGB(Red, Green, Blue) 방식이 있습니다. 빨강, 초록, 파랑 세 가지 색의 빛을 섞어서 원하는 색을 만드는 방식입니다. 각 색상은 0~255 사이의 값으로 표시하고 값이 커질수록 해당 색상의 빛이 밝아지는 원리입니다. RGB = (255, 255, 255) 일 때는 흰색이고, RGB = (0, 0, 0) 일 때는 검은색입니다. 그러나 OpenCV는 그 반대의 순서인 BGR로 표현합니다. 예를 들어, 빨간색은 RGB 값으로 (255, 0, 0)이지만 BGR 값으로는 (0, 0, 255)입니다.

RGBA는 RGB에 A(알파, alpha)가 추가된 색상 표기법입니다. A는 배경의 투명도를 의미합니다. A 역시 0~255의 값을 가질 수 있지만, 배경의 투명도를 표현하기 위해 0과 255만 사용하는 경우가 많습니다. A값이 255면 흰색, 0이면 검은색입니다. 

이제 아래의 예제 코드를 살펴보겠습니다. cv2.imread() 함수에 두 번째 파라미터로 cv2.IMREAD_COLOR를 넣어주면 BGR 방식으로 이미지를 읽습니다. cv2.IMREAD_UNCHANGED인 경우 이미지가 알파 채널을 가지고 있는 경우 BGRA 방식으로 읽습니다.

# BGR, BGRA, Ahlpha 채널 (rgba.py)

import cv2
import numpy as np

# 기본 값 옵션
img = cv2.imread('../img/opencv_logo.png')   
# IMREAD_COLOR 옵션                   
bgr = cv2.imread('../img/opencv_logo.png', cv2.IMREAD_COLOR)    
# IMREAD_UNCHANGED 옵션
bgra = cv2.imread('../img/opencv_logo.png', cv2.IMREAD_UNCHANGED) 
# 각 옵션에 따른 이미지 shape
print("default", img.shape, "color", bgr.shape, "unchanged", bgra.shape) 

cv2.imshow('bgr', bgr)
cv2.imshow('bgra', bgra)
cv2.imshow('alpha', bgra[:,:,3])  # 알파 채널만 표시
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

파라미터를 cv2.IMREAD_COLOR로 전달한 것과 아무 파라미터를 전달하지 않은 이미지의 차이는 없습니다. 배경이 검은색이다 보니 OpenCV라는 검은색 글씨도 안 보입니다. 또한, 첫 번째와 두 번째 이미지 모두 shape가 (240, 195, 3)입니다. 반면 세 번째 이미지는 알파 채널이 하나 더 있어 shape가 (240, 195, 4)입니다. 세 번째 이미지에서 전경의 알파 값은 255, 배경의 알파 값은 0인 것을 알 수 있습니다. 알파 값이 255면 흰색, 0이면 검은색이기 때문입니다. 첫 번째, 두 번째 이미지와 달리 세 번째 이미지는 알파 채널만 표시했으므로 전경과 배경을 쉽게 분리해서 볼 수 있습니다. 이런 이유로 알파 채널은 마스크 채널(mask channel)이라고도 부릅니다.

BGR 색상 이미지를 회색조 이미지로 변환하기

컬러 이미지를 회색조 이미지로 변환하는 것은 이미지 연산의 양을 줄여서 속도를 높이는 데 꼭 필요합니다. 처음부터 회색조로 읽어 들이는 함수는 cv2.imread(img, cv2.IMREAD_GRAYSCALE)입니다. cv2.imread() 함수의 두 번째 파라미터로 cv2.IMREAD_GRAYSCALE을 전달하면 됩니다. 그러나 처음에는 BGR 컬러 이미지로 읽어 들이고 그 이후에 회색조로 변환해야 할 때도 있습니다. 이는 cv2.cvtcolor() 함수로 구현할 수 있습니다. convert color의 약자입니다.

아래 코드는 색상 이미지를 회색조 이미지로 변환하는 두 가지 방법을 보여줍니다. 첫 번째 방법은 평균값을 이용해 직접 구현하는 것이고, 두 번째 방법은 OpenCV에서 제공하는 cv2.cvtcolor() 함수를 이용하는 것입니다.

# BGR 색상 이미지를 회색조 이미지로 변환 (bgr2gray.py)

import cv2
import numpy as np

img = cv2.imread('../img/yeosu.jpg')

img2 = img.astype(np.uint16)                # dtype 변경 ---①
b,g,r = cv2.split(img2)                     # 채널 별로 분리 ---②
#b,g,r = img2[:,:,0], img2[:,:,1], img2[:,:,2]
gray1 = ((b + g + r)/3).astype(np.uint8)    # 평균 값 연산후 dtype 변경 ---③

gray2 = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # BGR을 그레이 스케일로 변경 ---④
cv2.imshow('original', img)
cv2.imshow('gray1', gray1)
cv2.imshow('gray2', gray2)

cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

gray1 이미지는 평균값을 이용해 회색조 이미지로 표현하는 알고리즘을 직접 구현한 것입니다. 평균을 구하기 전에 dtype을 unit16으로 바꿔주었습니다. 3 채널의 값을 합하면 255보다 큰 값이 나올 수 있기 때문입니다. 평균을 구한 뒤에는 다시 unit8로 바꿔줍니다. 

cv2.split(img2) 함수는 BGR 채널별로 분리해서 튜플로 반환합니다. 이는 numpy 슬라이싱과 동일합니다. 따라서 아래의 두 코드는 동일한 코드입니다.

b, g, r = cv2.split(img2)
b, g, r = img2[:, :, 0], img2[:, :, 1], img2[:, :, 2]

그러나 BGR 색상 이미지를 회색조 이미지로 바꾸기 위해서 단순히 평균값을 취하는 것 말고 함수를 이용하는 방법도 있습니다. 이는 cv2.cvtcolor(img, flag) 함수를 사용하면 되며, flag 파라미터에 cv2.COLOR_BRG2GRAY를 넣어주면 됩니다.

flag 파라미터는 총 274개이지만, 자주 사용되는 것은 아래와 같습니다.

cv2.COLOR_BGR2GRAY: BGR 색상 이미지를 회색조 이미지로 변환
cv2.COLOR_GRAY2BGR: 회색조 이미지를 BGR 색상 이미지로 변환
cv2.COLOR_BGR2RGB: BGR 색상 이미지를 RGB 색상 이미지로 변환
cv2.COLOR_BGR2HSV: BGR 색상 이미지를 HSV 색상 이미지로 변환
cv2.COLOR_HSV2BGR: HSV 색상 이미지를 BGR 색상 이미지로 변환
cv2.COLOR_BGR2YUV: BGR 색상 이미지를 YUV 색상 이미지로 변환
cv2.COLOR_YUV2BGR: YUB 색상 이미지를 BGR 색상 이미지로 변환

HSV, YUV에 대해서는 뒤이어 살펴보겠습니다. 참고로 cv2.COLOR_GRAY2BGR가 회색조 이미지를 BGR 색상 이미지로 변환하는 파라미터인데, 이는 실제 회색조 이미지를 색깔이 있는 이미지로 바꿔준다는 뜻이 아닙니다. 2차원의 배열을 갖는 이미지를 3개 채널 모두 같은 값을 갖는 3차원 배열로 변환한다는 뜻입니다. 이미지 간 연산을 할 때 차원이 다르면 연산이 불가능하므로 차원을 맞추기 위해 필요한 작업입니다.

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

HSV 방식

HSV 방식은 RGB와 마찬가지로 3개의 채널을 갖는 색상 이미지 표현법입니다. 3개의 채널은 H(Hue, 색조), S(Saturation, 채도), V(Value, 명도)입니다. 

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: 위키백과

위 그림에서 H, S, V를 보면 이해가 쉬울 것입니다. H 값은 이미지가 어떤 색상인지를 나타냅니다. S는 이미지의 색상이 얼마나 순수하게 포함되어 있는지를 나타냅니다. V는 색상이 얼마나 밝은지 어두운지를 표현합니다. cv2.cvtColor() 함수에서 두 번째 파라미터로 cv2.COLOR_BGR2HSV를 넣어주면 BGR 방식을 HSV 방식으로 변환합니다. cv2.COLOR_HSV2BGR을 넣어주면 HSV 방식을 BGR 방식으로 변환합니다. 아래는 BGR 방식을 HSV 방식으로 변환하는 예제 코드입니다.

# BGR을 HSV로 변환 (bgr2hsv.py)

import cv2
import numpy as np

#---① BGR 컬러 스페이스로 원색 픽셀 생성
red_bgr = np.array([[[0,0,255]]], dtype=np.uint8)   # 빨강 값만 갖는 픽셀
green_bgr = np.array([[[0,255,0]]], dtype=np.uint8) # 초록 값만 갖는 픽셀
blue_bgr = np.array([[[255,0,0]]], dtype=np.uint8)  # 파랑 값만 갖는 픽셀
yellow_bgr = np.array([[[0,255,255]]], dtype=np.uint8) # 노랑 값만 갖는 픽셀

#---② BGR 컬러 스페이스를 HSV 컬러 스페이스로 변환
red_hsv = cv2.cvtColor(red_bgr, cv2.COLOR_BGR2HSV);
green_hsv = cv2.cvtColor(green_bgr, cv2.COLOR_BGR2HSV);
blue_hsv = cv2.cvtColor(blue_bgr, cv2.COLOR_BGR2HSV);
yellow_hsv = cv2.cvtColor(yellow_bgr, cv2.COLOR_BGR2HSV);

#---③ HSV로 변환한 픽셀 출력
print("red:",red_hsv)
print("green:", green_hsv)
print("blue", blue_hsv)
print("yellow", yellow_hsv)
OpenCV 형상 인식 - OpenCV hyeongsang insig

BGR이 (0, 0, 255)인 색을 HSV로 표현하면 (0, 255, 255)입니다. 색상을 알아내기 위해서 RGB 방식은 세 가지 채널의 값을 모두 알아야 하지만, HSV 방식은 오직 H값 하나만 알면 되므로 좀 더 편리하고 효과적입니다.

YUV, YCbCr 방식

YUV 방식은 YCbCr 방식이라고도 하며, Y는 밝기(Luma), U는 밝기와 파란색과의 색상 차(Chroma Blue, Cb), V는 밝기와 빨간색과의 색상 차(Chroma Red, Cr)를 의미합니다. Y(밝기)에는 많은 비트수를 할당하고 U(Cb)와 V(Cr)에는 적은 비트수를 할당하여 데이터를 압축하는 효과를 갖습니다. 아래는 Y=0.5일 때, V와 U에 따른 YUV 방식의 색상 이미지 영역입니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: 위키백과, Y=0.5일 때

아래는 BGR 값을 YUV 값으로 변환하는 예제 코드입니다.

# BGR 값을 YUV로 변환 (bgr2yuv.py)

import cv2
import numpy as np

#---① BGR 컬러 스페이스로 3가지 밝기의 픽셀 생성
dark = np.array([[[0,0,0]]], dtype=np.uint8)        # 3 채널 모두 0인 가장 어두운 픽셀
middle = np.array([[[127,127,127]]], dtype=np.uint8) # 3 채널 모두 127인 중간 밝기 픽셀
bright = np.array([[[255,255,255]]], dtype=np.uint8) # 3 채널 모두 255인 가장 밝은 픽셀

#---② BGR 컬러 스페이스를 YUV 컬러 스페이스로 변환
dark_yuv = cv2.cvtColor(dark, cv2.COLOR_BGR2YUV)
middle_yuv = cv2.cvtColor(middle, cv2.COLOR_BGR2YUV)
bright_yuv = cv2.cvtColor(bright, cv2.COLOR_BGR2YUV)

#---③ YUV로 변환한 픽셀 출력
print("dark:",dark_yuv)
print("middle:", middle_yuv)
print("bright", bright_yuv)
OpenCV 형상 인식 - OpenCV hyeongsang insig

BGR값은 (0, 0, 0), (127, 127, 127), (255, 255, 255)로 어두운 픽셀, 중간 밝기의 픽셀, 가장 밝은 픽셀입니다. 이를 YUV 방식으로 변환하면 각각 (0, 128, 128), (127, 128, 128), (255, 128, 128)입니다. 맨 처음 값인 Y가 밝기를 뜻한다고 했습니다. 두 번째, 세 번째 값은 동일한데 Y값만 0, 127, 255로 바뀝니다. 즉 어두운 값에서 밝은 값으로 변환하는 것을 볼 수 있습니다. 밝기에 좀 더 신경을 써야 한다면 BGR 방식보다 YUV 방식을 사용하는 것이 더 현명한 방법이겠죠?

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

요약하자면 OpenCV에서 색상을 표현하는 방식은 네 가지가 있습니다. BGR 방식, BGRA 방식, HSV 방식, YUV 방식입니다. BGR 방식은 전통적인 RGB 방식과 유사하며 그 순서만 반대입니다. BGRA 방식은 BGR 방식에서 투명도를 나타내는 A(알파) 값이 추가된 방식입니다. HSV 방식은 색조, 채도, 명도를 이용해서 색상을 표현하는 방식으로 H만 알면 색조는 어느 정도 파악이 가능합니다. 따라서 색조를 한눈에 알고자 한다면 HSV 방식을 사용하면 됩니다. YUV 방식은 밝기에 더 신경을 써야 하는 경우에 사용하면 좋습니다.

이번 포스팅에서는 바이너리 이미지를 만드는 대표적인 방법인 스레시홀딩에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/04.img_processing

스레시홀딩(Thresholding)

스레시홀딩은 바이너리 이미지를 만드는 가장 대표적인 방법입니다. 바이너리 이미지(binary image)란 검은색과 흰색만으로 표현한 이미지를 의미합니다. 스레시홀딩이란 여러 값을 어떤 임계점을 기준으로 두 가지 부류로 나누는 방법을 의미합니다.

전역 스레시홀딩

어떤 임계값을 정한 뒤 픽셀 값이 임계값을 넘으면 255, 임계값을 넘지 않으면 0으로 지정하는 방식을 전역 스레시홀딩이라고 합니다. 이런 작업은 간단하게 numpy로 연산할 수 있지만, OpenCV에서 cv2.threshold() 함수로 구현할 수도 있습니다. 아래는 전역 스레시홀딩 작업을 numpy 연산과 cv2.threshold() 함수를 통해 수행하는 과정을 보여줍니다.

# 전역 스레시홀딩 (threshold.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

img = cv2.imread('../img/gray_gradient.jpg', cv2.IMREAD_GRAYSCALE) #이미지를 그레이 스케일로 읽기

# --- ① NumPy API로 바이너리 이미지 만들기
thresh_np = np.zeros_like(img)   # 원본과 동일한 크기의 0으로 채워진 이미지
thresh_np[ img > 127] = 255      # 127 보다 큰 값만 255로 변경

# ---② OpenCV API로 바이너리 이미지 만들기
ret, thresh_cv = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY) 
print(ret)  # 127.0, 바이너리 이미지에 사용된 문턱 값 반환

# ---③ 원본과 결과물을 matplotlib으로 출력
imgs = {'Original': img, 'NumPy API':thresh_np, 'cv2.threshold': thresh_cv}
for i , (key, value) in enumerate(imgs.items()):
    plt.subplot(1, 3, i+1)
    plt.title(key)
    plt.imshow(value, cmap='gray')
    plt.xticks([]); plt.yticks([])

plt.show()
OpenCV 형상 인식 - OpenCV hyeongsang insig

우선, 검은색에서 흰색으로 점점 변하는 그라데이션 이미지를 회색조로 읽습니다. 첫 번째로, numpy 연산을 통해 픽셀 값이 127보다 크면 255, 픽셀 값이 127보다 작거나 같으면 0으로 바꾸는 작업을 수행했습니다. Numpy API가 그 결과입니다. 이 작업은 간단하게 cv2.threshold(img, 127, 255, cv2.THRESH_BINARY) 함수를 호출하여 수행할 수도 있습니다. cv2.threshold() 함수 사용법은 다음과 같습니다.

  • ret, out = cv2.threshold(img, threshold, value, type_flag)
    img: 변환할 이미지
    threshold: 스레시홀딩 임계값
    value: 임계값 기준에 만족하는 픽셀에 적용할 값
    type_flag: 스레시홀딩 적용 방법

type_flag 값은 다음과 같습니다.

cv2.THRESH_BINARY: 픽셀 값이 임계값을 넘으면 value로 지정하고, 넘지 못하면 0으로 지정cv2.THRESH_BINARY_INV: cv.THRESH_BINARY의 반대cv2.THRESH_TRUNC: 픽셀 값이 임계값을 넘으면 value로 지정하고, 넘지 못하면 원래 값 유지cv2.THRESH_TOZERO: 픽셀 값이 임계값을 넘으면 원래 값 유지, 넘지 못하면 0으로 지정cv2.THRESH_TOZERO_INV: cv2.THRESH_TOZERO의 반대

이 함수는 두 개의 결과를 반환하는데 첫 번째 결과인 ret은 스레시홀딩에 사용한 임계값이고, 두번째 결과인 out은 스레시홀딩이 적용된 바이너리 이미지입니다. 대부분 첫번째 결과인 ret은 threshold 파라미터로 전달한 값과 동일합니다.

아래 코드는 여러 type_flag를 활용한 예제입니다. 참고로 0은 검은색, 255는 흰색임에 유의하시기 바랍니다.

# 스레시홀딩 플래그 (threshold_flag.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

img = cv2.imread('../img/gray_gradient.jpg', cv2.IMREAD_GRAYSCALE)

_, t_bin = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)
_, t_bininv = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY_INV)
_, t_truc = cv2.threshold(img, 127, 255, cv2.THRESH_TRUNC)
_, t_2zr = cv2.threshold(img, 127, 255, cv2.THRESH_TOZERO)
_, t_2zrinv = cv2.threshold(img, 127, 255, cv2.THRESH_TOZERO_INV)

imgs = {'origin':img, 'BINARY':t_bin, 'BINARY_INV':t_bininv, \
        'TRUNC':t_truc, 'TOZERO':t_2zr, 'TOZERO_INV':t_2zrinv}
for i, (key, value) in enumerate(imgs.items()):
    plt.subplot(2,3, i+1)
    plt.title(key)
    plt.imshow(value, cmap='gray')
    plt.xticks([]);    plt.yticks([])
    
plt.show()
OpenCV 형상 인식 - OpenCV hyeongsang insig

type_flag 파라미터에 따라 다양한 결과가 도출된 것을 볼 수 있습니다.

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

오츠의 이진화 알고리즘

바이너리 이미지를 만들 때 가장 중요한 점은 임계값을 얼마로 정하냐 하는 것입니다. 1979년 오츠 노부유키는 반복적인 시도 없이 한 번에 임계값을 찾을 수 있는 방법을 찾아냈습니다. 이것이 바로 오츠의 이진화 알고리즘(Otsu's binarization method)입니다. 오츠의 알고리즘은 임계값을 임의로 정해 픽셀을 두 부류로 나누고 두 부류의 명암 분포를 구하는 작업을 반복합니다. 모든 경우의 수 중에서 두 부류의 명암 분포가 가장 균일할 때의 임계값을 선택합니다. 아래 예제에서는 임계값이 120~140 사이일 때 이미지가 가장 선명하다는 것을 알 수 있습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

OpenCV 함수를 활용하면 오츠의 알고리즘을 적용할 수 있습니다. cv2.threshold() 함수의 마지막 파라미터로 cv2.THRESH_OTSU를 전달하기만 하면 됩니다. 오츠의 알고리즘은 최적의 임계값을 찾아주므로 cv2.threshold() 함수에 전달하는 threshold 파라미터는 아무 값이어도 상관없습니다. 어차피 무시되기 때문입니다.

아래는 오츠의 알고리즘을 활용하여 최적의 임계값을 찾는 예제 코드입니다.

오츠의 알고리즘을 적용한 스레시홀딩 (threshold_otsu.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

# 이미지를 그레이 스케일로 읽기
img = cv2.imread('../img/scaned_paper.jpg', cv2.IMREAD_GRAYSCALE) 
# 경계 값을 130으로 지정  ---①
_, t_130 = cv2.threshold(img, 130, 255, cv2.THRESH_BINARY)        
# 경계 값을 지정하지 않고 OTSU 알고리즘 선택 ---②
t, t_otsu = cv2.threshold(img, -1, 255,  cv2.THRESH_BINARY | cv2.THRESH_OTSU) 
print('otsu threshold:', t)                 # Otsu 알고리즘으로 선택된 경계 값 출력

imgs = {'Original': img, 't:130':t_130, 'otsu:%d'%t: t_otsu}
for i , (key, value) in enumerate(imgs.items()):
    plt.subplot(1, 3, i+1)
    plt.title(key)
    plt.imshow(value, cmap='gray')
    plt.xticks([]); plt.yticks([])

plt.show()
OpenCV 형상 인식 - OpenCV hyeongsang insig

원본 이미지의 글씨는 선명하지 않습니다. 하지만 바이너리 이미지로 변환하니 글씨가 좀 더 선명해졌습니다. 맨 왼쪽은 원본 이미지, 두 번째 이미지는 임계값을 130으로 지정해준 바이너리 이미지, 세 번째 이미지는 오츠의 알고리즘을 적용한 바이너리 이미지입니다. 오츠의 알고리즘에 따르면 최적의 임계값은 131 임을 알 수 있습니다. (세 번째 이미지 상단에 otsu: 131이라고 표시되어 있음) 아래 코드는 오츠의 알고리즘을 적용하는 코드입니다.

t, t_otsu = cv2.threshold(img, -1, 255,  cv2.THRESH_BINARY | cv2.THRESH_OTSU) 

두 번째 파라미터인 -1은 threshold를 전달하는 값입니다. 이미 설명드린 대로 오츠의 알고리즘에서 이 값은 무시되므로 아무 값이나 넣어도 상관없습니다.

오츠의 알고리즘이 최적의 임계값을 자동으로 찾아준다는 장점이 있지만, 모든 경우의 수에 대해 조사해야 하므로 속도가 빠르지 않다는 단점도 있습니다.

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

적응형 스레시홀딩

위에서 설명한 전역 스레시홀딩이 매번 좋은 성능을 내는 것은 아닙니다. 원본 이미지에서 조명이 일정하지 않거나 배경색이 여러 개인 경우에는 하나의 임계값으로 선명한 바이너리 이미지를 만들어내기 힘들 수도 있습니다. 이때는 이미지를 여러 영역으로 나눈 뒤, 그 주변 픽셀 값만 활용하여 임계값을 구해야 하는데, 이를 적응형 스레시홀딩(Adaptive Thresholding)이라고 합니다. 적응형 스레시홀딩은 다음 함수로 제공합니다.

  • cv2.adaptiveThreshold(img, value, method, type_flag, block_size, C)
    img: 입력영상
    value: 임계값을 만족하는 픽셀에 적용할 값
    method: 임계값 결정 방법
    type_flag: 스레시홀딩 적용 방법 (cv2.threshod()와 동일)
    block_size: 영역으로 나눌 이웃의 크기(n x n), 홀수
    C: 계산된 임계값 결과에서 가감할 상수(음수 가능)

method 값은 다음과 같습니다.

cv2.ADAPTIVE_THRESH_MEAN_C: 이웃 픽셀의 평균으로 결정
cv2.ADAPTIVE_THRESH_GAUSSIAN_C: 가우시안 분포에 따른 가중치의 합으로 결정

아래 코드는 적응형 스레시홀딩을 적용한 예시입니다.

# 적응형 스레시홀딩 적용 (threshold_adapted.py)

import cv2
import numpy as np 
import matplotlib.pyplot as plt 

blk_size = 9        # 블럭 사이즈
C = 5               # 차감 상수 
img = cv2.imread('../img/sudoku.png', cv2.IMREAD_GRAYSCALE) # 그레이 스케일로  읽기

# ---① 오츠의 알고리즘으로 단일 경계 값을 전체 이미지에 적용
ret, th2 = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)

# ---② 어뎁티드 쓰레시홀드를 평균과 가우시안 분포로 각각 적용
th2 = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_MEAN_C,\
                                      cv2.THRESH_BINARY, blk_size, C)
th3 = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, \
                                     cv2.THRESH_BINARY, blk_size, C)

# ---③ 결과를 Matplot으로 출력
imgs = {'Original': img, 'Global-Otsu:%d'%ret:th2, \
        'Adapted-Mean':th2, 'Adapted-Gaussian': th3}
for i, (k, v) in enumerate(imgs.items()):
    plt.subplot(2,2,i+1)
    plt.title(k)
    plt.imshow(v,'gray')
    plt.xticks([]),plt.yticks([])

plt.show()
OpenCV 형상 인식 - OpenCV hyeongsang insig

오른쪽 상단은 오츠의 알고리즘을 통해 전역 스레시홀딩을 적용한 바이너리 이미지입니다. 왼쪽 아래는 검정색으로, 오른쪽 위는 흰색으로 변해 이미지를 식별하기가 더 어려워졌습니다. 원본 이미지 좌측 하단이 우측 상단보다 더 그늘지고 어두워 발생하는 현상입니다. 전역 스레시홀딩을 하면 발생하는 전형적인 문제점입니다. 하지만 적응형 스레시홀딩을 적용한 아래 두 바이너리 이미지는 상당히 선명합니다. 평균값(Adapted-Mean)을 활용한 것이 가우시안 분포(Adapted-Gaussian)을 활용한 것보다 더 선명한데 그만큼 잡티가 조금 있습니다. 반면, 가우시안 분포를 활용한 것은 평균값을 활용한 것에 비해 선명도는 조금 떨어지지만 잡티가 더 적습니다.

위 예제의 적응형 스레시홀딩 알고리즘은 다음과 같습니다. 우선, 전체 이미지에 총 9개의 블록을 설정합니다. 이미지를 9등분 한다고 보시면 됩니다. 그 다음 각 블록별로 임계값을 정합니다. 이때, cv2.ADAPTIVE_THRESH_MEAN_C를 파라미터로 전달하면 각 블록의 이웃 픽셀의 평균으로 임계값을 정합니다. cv2.ADAPTIVE_THRESH_GAUSSIAN_C를 파라미터로 전달하면 가우시안 분포에 따른 가중치의 합으로 임계값을 정합니다. 정해진 임계값을 바탕으로 각 블록별로 스레시홀딩을 합니다. 그렇게 하면 전역 스레시홀딩을 적용한 것보다 더 선명하고 부드러운 결과를 얻을 수 있습니다.

대부분의 이미지는 그림자가 있거나 조명 차이가 있습니다. 따라서 전역 스레시홀딩보다 적응형 스레시홀딩을 더 많이 사용합니다. 

이번 포스팅에서는 이미지 연산에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/04.img_processing

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

이미지 연산

이미지 연산을 위해서 numpy 연산을 활용하는 방법이 있습니다. 하지만 OpenCV에서도 이미지 연산을 위한 함수를 제공합니다. OpenCV에서 굳이 연산을 위한 함수를 제공하는 이유는 값의 범위 때문입니다. 한 픽셀이 가질 수 있는 값의 범위는 0~255인데, 연산의 결과가 255보다 크거나 0보다 작을 수 있어서 결과 값을 0~255로 제한할 필요가 있습니다. OpenCV에서 제공하는 사칙연산 함수는 다음과 같습니다.

  • cv2.add(src1, src2, dest, mask, dtype): src1과 src2 더하기src1: 첫 번째 입력 이미지src2: 두 번째 입력 이미지dest(optional): 출력 영상mask(optional): mask 값이 0이 아닌 픽셀만 연산dtype(optional): 출력 데이터 타입(dtype)
  • cv2.subtract(src1, src2, dest, mask, dtype): src1에서 src2 빼기모든 파라미터는 cv2.add()와 동일
  • cv2.multiply(src1, src2, dest, scale, dtype): src1과 src2 곱하기scale(optional): 연산 결과에 추가 연산할 값
  • cv2.divide(src1, src2, dest, scale, dtype): src1을 src2로 나누기모든 파라미터는 cv2.multiply()와 동일

아래는 numpy 연산과 OpenCV 연산 함수를 이용한 사칙 연산 예제 코드입니다.

# 이미지의 사칙 연산 (arithmatic.py)

import cv2
import numpy as np

# ---① 연산에 사용할 배열 생성
a = np.uint8([[200, 50]]) 
b = np.uint8([[100, 100]])

#---② NumPy 배열 직접 연산
add1 = a + b
sub1 = a - b
mult1 = a * 2
div1 = a / 3

# ---③ OpenCV API를 이용한 연산
add2 = cv2.add(a, b)
sub2 = cv2.subtract(a, b)
mult2 = cv2.multiply(a , 2)
div2 = cv2.divide(a, 3)

#---④ 각 연산 결과 출력
print(add1, add2)
print(sub1, sub2)
print(mult1, mult2)
print(div1, div2)
OpenCV 형상 인식 - OpenCV hyeongsang insig

numpy 연산 결과와 OpenCV 연산 함수를 활용한 연산 결과가 서로 다르다는 걸 볼 수 있습니다. 좌측이 numpy로 연산한 결과고, 우측이 OpenCV 함수로 연산한 결과입니다.

200 + 100 = 300인데, 이 값은 255를 초과합니다. unit8 타입의 값의 범위는 0 ~ 255이므로 255를 넘는 값은 다시 0부터 카운팅을 합니다. 200 + 100을 numpy로 계산하면 300인데, 이는 300 - 255 - 1 = 44입니다. 반면 cv2.add() 함수를 활용하면 255를 초과하는 모든 값은 255로 반환합니다. 반면, 50 + 100 = 150인데, 이는 numpy 연산이나 cv2.add() 연산이나 결과가 동일합니다. 150은 255를 넘지 않기 때문입니다. 마찬가지로 50 - 100 = -50인데, numpy 연산에서의 결과는 206입니다. -50 + 255 + 1 = 206이기 때문입니다. cv2.subtract()로 계산한 결과는 0입니다. OpenCV에서는 0보다 작은 모든 값을 0으로 반환하기 때문입니다. 곱하기와 나누기 연산도 255를 초과하거나 0보다 작은 값을 갖지 않고, 소수점은 갖지 않습니다.

cv2.add() 함수에 세 번째 파라미터를 전달하면 첫 번째와 두 번째 파라미터의 합을 세 번째 파라미터에 할당합니다. 따라서 아래의 세 코드의 결과는 똑같습니다.

c = cv2.add(a, b)
c = cv2.add(a, b, None)
cv2.add(a, b, c)

첫 번째 코드는 a와 b를 더한 뒤 c에 할당하는 전형적인 코드입니다. 두 번째는 a와 b를 더한 뒤 None과 c에 할당합니다. 결과는 첫 번째와 동일합니다. 마지막 코드는 a와 b를 더한 뒤 세 번째 파라미터인 c에 결과를 할당합니다.

네 번째 파라미터인 mask에 numpy 배열을 전달한 경우에는 mask의 값이 0이 아닌 위치(인덱스)에 있는 픽셀만 연산을 수행합니다. 아래의 코드를 예로 들어 보겠습니다.

# mask와 누적 할당 연산 (arithmatic_mask.py)

import cv2
import numpy as np

#---① 연산에 사용할 배열 생성
a = np.array([[1, 2]], dtype=np.uint8)
b = np.array([[10, 20]], dtype=np.uint8)
#---② 2번째 요소가 0인 마스크 배열 생성 
mask = np.array([[1, 0]], dtype=np.uint8)

#---③ 누적 할당과의 비교 연산
c1 = cv2.add(a, b, None, mask)
print(c1)
c2 = cv2.add(a, b, b.copy(), mask)
print(c2, b)
c3 = cv2.add(a, b, b, mask)
print(c3, b)
OpenCV 형상 인식 - OpenCV hyeongsang insig

만약 코드가 c1 = cv2.add(a, b, None)라면 c1은 [[11, 22]]가 되어야 합니다. 하지만 배열이 [1, 0]인 mask를 지정해주었습니다. 앞서 mask 값이 0이 아닌 위치에 있는 픽셀만 연산해준다고 했습니다. mask 배열의 0번째 index는 1이고, 1번째 index는 0입니다. 따라서 0번째 index에 해당하는 값만 연산을 수행합니다. 0번째 index에 해당하는 값만 0이 아니기 때문입니다. 즉, 0번째 index에 있는 1과 10의 합(1+10 = 11)인 연산만 수행하고, 1번째 index에 있는 2와 20의 합(2+20 = 22)인 연산은 수행하지 않습니다. 따라서 c1 = cv2.add(a, b, None, mask)를 실행했을 때 c1 = [[11, 0]]이 됩니다.

cv2.add(a, b, b.copy(), mask)를 실행하면 마찬가지로 a와 b의 첫 번째 요소만 더하고, 두 번째 요소는 더하지 않습니다. 하지만 세 번째 파라미터가 b.copy()입니다. b = [10, 20]입니다. 따라서 a와 b의 첫 번째 요소만 더하여 b의 첫 번째 요소에 적용하고, b의 두 번째 요소는 그대로 둡니다. 따라서 결과는 [[11, 20]]입니다. 

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

이미지 합성

두 이미지를 합성할 때 위에서 살펴본 numpy의 합이나 cv2.add() 함수만으로는 좋은 결과를 얻을 수 없습니다. numpy의 합 연산을 수행하면 픽셀 값이 255가 넘는 경우 초과 값만을 갖기 때문에 이미지가 검은색에 가깝게 됩니다. 예를 들어, 150와 180을 더하면 330이므로, 최종 결괏값은 255에서의 초과 값만큼인 74가 됩니다. 그러면 일부 영역이 거뭇거뭇하게 됩니다. 반면 cv2.add() 연산을 하면 대부분의 픽셀 값이 255 가까이 몰리는 현상이 발생합니다. 따라서 영상이 전체적으로 하얗게 됩니다. 아래 코드는 이러한 예를 보여줍니다.

# 이미지 단순 합성 (blending_simple.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

# ---① 연산에 사용할 이미지 읽기
img1 = cv2.imread('../img/wing_wall.jpg')
img2 = cv2.imread('../img/yate.jpg')

# ---② 이미지 덧셈
img3 = img1 + img2  # 더하기 연산
img4 = cv2.add(img1, img2) # OpenCV 함수

imgs = {'img1':img1, 'img2':img2, 'img1+img2': img3, 'cv.add(img1, img2)': img4}

# ---③ 이미지 출력
for i, (k, v) in enumerate(imgs.items()):
    plt.subplot(2,2, i + 1)
    plt.imshow(v[:,:,::-1])
    plt.title(k)
    plt.xticks([]); plt.yticks([])

plt.show()
OpenCV 형상 인식 - OpenCV hyeongsang insig

img1과 img2를 합성한 사진의 예시입니다. 단순히 numpy로 img1+img2를 한 경우 255를 넘는 픽셀은 이상한 색상을 보이고 있습니다. 반면 cv2.add(img1, img2)를 수행한 경우 대부분의 값이 255에 몰려 전체적으로 하얀 픽셀이 많습니다. 두 결과 모두 좋지 않습니다. 

두 이미지를 제대로 합성하려면 각각의 이미지에 가중치를 주고 합해야 합니다. 예를 들어 두 이미지의 픽셀 값에 각각 50%씩 곱해서 더하면 됩니다. 이때 가중치를 조정할 수 있는데, 이 가중치를 알파(alpha) 값이라고 합니다. 새로운 이미지 알파(alpha)를 활용하여 합성 결과 픽셀 값 g(x)를 구하는 공식은 아래와 같습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig
  • cv2.addWeight(img1, alpha, img2, beta, gamma)
    img1, img2: 합성할 두 이미지
    alpha: img1에 지정할 가중치(알파 값)
    beta: img2에 지정할 가중치, 흔히 (1-alpha) 적용
    gamma: 연산 결과에 가감할 상수, 흔히 0 적용

아래 코드는 cv2.addWeight() 함수를 활용하여 이미지를 합성하는 예시 코드입니다.

# 알파 블렌딩 (blending_alpha.py)

import cv2
import numpy as np

alpha = 0.5 # 합성에 사용할 알파 값

#---① 합성에 사용할 영상 읽기
img1 = cv2.imread('../img/wing_wall.jpg')
img2 = cv2.imread('../img/yate.jpg')

# ---② NumPy 배열에 수식을 직접 연산해서 알파 블렌딩 적용
blended = img1 * alpha + img2 * (1-alpha)
blended = blended.astype(np.uint8) # 소수점 발생을 제거하기 위함
cv2.imshow('img1 * alpha + img2 * (1-alpha)', blended)

# ---③ addWeighted() 함수로 알파 블렌딩 적용
dst = cv2.addWeighted(img1, alpha, img2, (1-alpha), 0) 
cv2.imshow('cv2.addWeighted', dst)

cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

이 코드에서는 알파를 0.5로 잡았습니다. 알파 값이 0.5라는 것은 두 이미지에 동일한 가중치를 주고 합성한다는 뜻입니다. 왼쪽은 알파를 활용한 이미지 합성을 numpy로 직접 구현한 결과고 오른쪽은 cv2.addWeight() 함수를 활용한 결과입니다. 결과가 똑같은 것을 볼 수 있습니다.

좀 더 나아가, 트랙바를 움직여 알파 값을 조정하면서 사람이 서서히 사자로 바뀌는 것처럼 보이게 해 보겠습니다. 이는 영화에서 변신 장면을 구현할 때 쓰는 기술 중 하나이기도 합니다.

# 트랙바로 알파 블렌딩 (blending_alpha_trackbar.py)

import cv2
import numpy as np

win_name = 'Alpha blending'     # 창 이름
trackbar_name = 'fade'          # 트렉바 이름

# ---① 트렉바 이벤트 핸들러 함수
def onChange(x):
    alpha = x/100
    dst = cv2.addWeighted(img1, 1-alpha, img2, alpha, 0) 
    cv2.imshow(win_name, dst)


# ---② 합성 영상 읽기
img1 = cv2.imread('../img/man_face.jpg')
img2 = cv2.imread('../img/lion_face.jpg')

# ---③ 이미지 표시 및 트렉바 붙이기
cv2.imshow(win_name, img1)
cv2.createTrackbar(trackbar_name, win_name, 0, 100, onChange)

cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig
OpenCV 형상 인식 - OpenCV hyeongsang insig
OpenCV 형상 인식 - OpenCV hyeongsang insig

비트와이즈 연산

OpenCV를 활용하여 두 이미지의 비트 단위 연산을 할 수도 있습니다. 비트와이즈 연산은 두 이미지를 합성할 때 특정 영역만 선택하거나 특정 영역만 제외하는 등의 선별적인 연산에 도움이 됩니다. 비트와이즈 연산에 대해서 공간 제약상 자세하게 설명하지는 않겠습니다. 아래 그림을 보면 어느 정도 이해가 되실 겁니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://slideplayer.com/slide/5378944/

OpenCV에서 제공하는 비트와이즈 연산 함수는 아래와 같습니다.

cv2.bitwise_and(img1, img2, mask=None): 각 픽셀에 대해 AND 연산

cv2.bitwise_or(img1, img2, mask=None): 각 픽셀에 대해 OR 연산

cv2.bitwise_xor(img1, img2, mask=None): 각 픽셀에 대해 XOR 연산

cv2.bitwise_not(img1, img2, mask=None): 각 픽셀에 대해 NOT 연산

img1, img2는 연산을 할 이미지이며, 두 이미지는 동일한 shape를 가져야 합니다. mask는 0이 아닌 픽셀만 연산하게 합니다. 아래는 비트와이즈 연산의 예시 코드입니다.

# 비트와이즈 연산 (bitwise.py)

import numpy as np, cv2
import matplotlib.pylab as plt

#--① 연산에 사용할 이미지 생성
img1 = np.zeros( ( 200,400), dtype=np.uint8)
img2 = np.zeros( ( 200,400), dtype=np.uint8)
img1[:, :200] = 255         # 왼쪽은 흰색(255), 오른쪽은 검정색(0)
img2[100:200, :] = 255      # 위쪽은 검정색(0), 아래쪽은 흰색(255)

#--② 비트와이즈 연산
bitAnd = cv2.bitwise_and(img1, img2)
bitOr = cv2.bitwise_or(img1, img2)
bitXor = cv2.bitwise_xor(img1, img2)
bitNot = cv2.bitwise_not(img1)

#--③ Plot으로 결과 출력
imgs = {'img1':img1, 'img2':img2, 'and':bitAnd, 
          'or':bitOr, 'xor':bitXor, 'not(img1)':bitNot}
for i, (title, img) in enumerate(imgs.items()):
    plt.subplot(3,2,i+1)
    plt.title(title)
    plt.imshow(img, 'gray')
    plt.xticks([]); plt.yticks([])

plt.show()
OpenCV 형상 인식 - OpenCV hyeongsang insig

0은 검은색, 255은 흰색입니다. 불리언(boolean) 값으로 치환하면 0은 False, 0이 아닌 값은 True입니다. 따라서 검은색은 0이므로 False를 의미하고, 흰색은 255이므로 True를 의미합니다. 두 값의 AND 연산 결과 True가 되기 위해서는 두 값 모두 True여야 합니다. 따라서 AND 연산 후에는 img1과 img2의 흰색 부분이 겹치는 곳만 흰색으로 표시됩니다.

아래는 비트와이즈 연산으로 이미지 일부분을 원하는 모양으로 떼어내는 예제 코드입니다.

# bitwise_and 연산으로 마스킹하기 (bitwise_masking.py)

import numpy as np, cv2
import matplotlib.pylab as plt

#--① 이미지 읽기
img = cv2.imread('../img/yeosu_small.jpg')

#--② 마스크 만들기
mask = np.zeros_like(img)
cv2.circle(mask, (260,210), 100, (255,255,255), -1)
#cv2.circle(대상이미지, (원점x, 원점y), 반지름, (색상), 채우기)

#--③ 마스킹
masked = cv2.bitwise_and(img, mask)

#--④ 결과 출력
cv2.imshow('original', img)
cv2.imshow('mask', mask)
cv2.imshow('masked', masked)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

원본 이미지에서 완벽한 검은색 부분만 빼고는 모두 0보다 큰 픽셀 값을 가집니다. 두 번째 이미지인 흰색 원에서는 흰색 원 부분만 255의 값을 가지고 나머지는 0의 값을 가집니다. 따라서 원본 이미지와 원 이미지를 AND 연산하면 세 번째 이미지가 구해집니다. AND 연산은 True(0이 아닌 값)와 True(0이 아닌 값)가 합해진 부분만 True가 출력되기 때문입니다.

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

두 이미지의 차이

이제 두 이미지의 차를 구하는 방법에 대해 알아보겠습니다. 무작정 두 이미지의 픽셀 값을  빼면 음수가 나오므로 차의 절댓값을 취해야 합니다. 픽셀 값의 차를 구하는 함수는 다음과 같습니다.

  • diff = cv2.absdiff(img1, img2)
    img1, img2: 입력 이미지
    diff: 두 이미지의 차의 절대 값

이 함수를 활용한 예제를 들어보겠습니다.

# 두 이미지의 차를 통해 도면의 차이 찾아내기 (diff_absolute.py)

import numpy as np, cv2

#--① 연산에 필요한 영상을 읽고 그레이스케일로 변환
img1 = cv2.imread('../img/robot_arm1.jpg')
img2 = cv2.imread('../img/robot_arm2.jpg')
img1_gray = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
img2_gray = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

#--② 두 영상의 절대값 차 연산
diff = cv2.absdiff(img1_gray, img2_gray)

#--③ 차 영상을 극대화 하기 위해 쓰레시홀드 처리 및 컬러로 변환
_, diff = cv2.threshold(diff, 1, 255, cv2.THRESH_BINARY)
diff_red = cv2.cvtColor(diff, cv2.COLOR_GRAY2BGR)
diff_red[:,:,2] = 0

#--④ 두 번째 이미지에 변화 부분 표시
spot = cv2.bitwise_xor(img2, diff_red)

#--⑤ 결과 영상 출력
cv2.imshow('img1', img1)
cv2.imshow('img2', img2)
cv2.imshow('diff', diff)
cv2.imshow('spot', spot)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

img1과 img2를 딱 봤을 때는 거의 똑같은 이미지입니다. 틀린 그림 찾기처럼 다른 부분을 찾기가 쉽지 않습니다. 하지만 위 코드를 실행하면 어떤 부분이 다른지 한 번에 알 수 있습니다.

우선, 두 이미지를 읽어온 뒤 회색조로 변환합니다. 회색조로 변환된 두 이미지의 차를 구합니다. 그 차이를 뚜렷하게 하기 위해 cv2.threshold() 함수를 활용하여 픽셀 값이 1보다 큰 값은 255로 바꾸고 그렇지 않은 픽셀 값은 0으로 바꿨습니다. 색상을 표현하기 위해 cv2.cvtColor() 함수를 활용하여 색상 스케일을 맞추기 위해 차원을 변경해준 뒤, 빨간색으로 변경해줬습니다. 마지막으로 cv2.bitwise_xor() 연산을 해줬습니다. 차이가 있는 곳이 빨간색 영역으로 표시가 되었습니다.

이미지 합성과 마스킹

이제, 두 이미지를 합성하는 방법에 대해 배워보겠습니다. 일반적으로 하나의 이미지는 배경과 전경(배경이 아닌 실제 이미지)으로 나뉩니다. 예를 들어 푸른 잔디에 강아지가 있는 이미지를 생각해봅시다. 푸른 잔디는 배경이고, 강아지는 전경입니다. 우리가 원하는 게 푸른 잔디가 아닌 강아지라면 어떻게 해야 할까요? 이미지에서 강아지만을 추출해야 합니다. 하지만 이 작업은 컴퓨터 비전의 정점과도 같다고 합니다. 그만큼 쉽지만은 않다는 것이겠죠. 여기서는 우선 배경이 투명한 이미지를 활용하여 합성해보겠습니다. BGRA 색상 형식으로 표현할 때, 배경은 A(알파, alpha)가 0이고, 전경은 A가 255입니다. A가 0이면 투명하고, 255면 불투명하기 때문입니다. BGRA에 대해 모르시는 분은 OpenCV - 7. 이미지 색상 표현  방식(BGR, HSV, YUV)을 참고해주시기 바랍니다. BGRA를 활용하면 배경을 손쉽게 오려낼 수 있습니다. 아래 예를 들어보겠습니다.

# 투명 배경 PNG 파일을 이용한 합성 (addition_rgba_mask.py)

import cv2
import numpy as np

#--① 합성에 사용할 영상 읽기, 전경 영상은 4채널 png 파일
img_fg = cv2.imread('../img/opencv_logo.png', cv2.IMREAD_UNCHANGED)
img_bg = cv2.imread('../img/girl.jpg')

#--② 알파채널을 이용해서 마스크와 역마스크 생성
_, mask = cv2.threshold(img_fg[:,:,3], 1, 255, cv2.THRESH_BINARY)
mask_inv = cv2.bitwise_not(mask)

#--③ 전경 영상 크기로 배경 영상에서 ROI 잘라내기
img_fg = cv2.cvtColor(img_fg, cv2.COLOR_BGRA2BGR)
h, w = img_fg.shape[:2]
roi = img_bg[10:10+h, 10:10+w ]

#--④ 마스크 이용해서 오려내기
masked_fg = cv2.bitwise_and(img_fg, img_fg, mask=mask)
masked_bg = cv2.bitwise_and(roi, roi, mask=mask_inv)

#--⑥ 이미지 합성
added = masked_fg + masked_bg
img_bg[10:10+h, 10:10+w] = added

cv2.imshow('mask', mask)
cv2.imshow('mask_inv', mask_inv)
cv2.imshow('masked_fg', masked_fg)
cv2.imshow('masked_bg', masked_bg)
cv2.imshow('added', added)
cv2.imshow('result', img_bg)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

우선, 합성할 두 이미지를 읽습니다. 

_, mask = cv2.threshod(img_fg[:, :, 3], 1, 255, cv2.THRESH_BINARY)를 호출하여 배경과 전경을 분리하는 마스크를 만듭니다. OpenCV라고 쓰여있는 이미지는 배경이 투명합니다. 따라서 배경 부분은 BRGA의 A값이 0입니다. 반면 배경이 아닌 전경 부분은 A가 0이 아닙니다. 따라서 A가 1 이상이면 255, 1 미만이면 0으로 바꾸어주면 배경은 검은색, 전경은 흰색이 됩니다. mask_inv = cv2.bitwise_not(mask)이므로 mask_inv는 mask의 반대입니다. 즉, 배경은 흰색, 전경은 검은색입니다. 이 두 mask를 활용하여 여수 이미지와 OpenCV 이미지를 합성했습니다.

아래 예제는 색상 별로 추출하는 코드입니다. 참고로 cv2.inRange(hsv, lower, upper) 함수는 hsv의 모든 값 중 lower와 upper 범위 사이에 있는 값은 255, 나머지 값은 0으로 변환합니다.

# HSV 색상으로 마스킹 (hsv_color_mask.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

#--① 큐브 영상 읽어서 HSV로 변환
img = cv2.imread("../img/cube.jpg")
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)

#--② 색상별 영역 지정
blue1 = np.array([90, 50, 50])
blue2 = np.array([120, 255,255])
green1 = np.array([45, 50,50])
green2 = np.array([75, 255,255])
red1 = np.array([0, 50,50])
red2 = np.array([15, 255,255])
red3 = np.array([165, 50,50])
red4 = np.array([180, 255,255])
yellow1 = np.array([20, 50,50])
yellow2 = np.array([35, 255,255])

# --③ 색상에 따른 마스크 생성
mask_blue = cv2.inRange(hsv, blue1, blue2)
mask_green = cv2.inRange(hsv, green1, green2)
mask_red = cv2.inRange(hsv, red1, red2)
mask_red2 = cv2.inRange(hsv, red3, red4)
mask_yellow = cv2.inRange(hsv, yellow1, yellow2)

#--④ 색상별 마스크로 색상만 추출
res_blue = cv2.bitwise_and(img, img, mask=mask_blue)
res_green = cv2.bitwise_and(img, img, mask=mask_green)
res_red1 = cv2.bitwise_and(img, img, mask=mask_red)
res_red2 = cv2.bitwise_and(img, img, mask=mask_red2)
res_red = cv2.bitwise_or(res_red1, res_red2)
res_yellow = cv2.bitwise_and(img, img, mask=mask_yellow)

#--⑤ 결과 출력
imgs = {'original': img, 'blue':res_blue, 'green':res_green, 
                            'red':res_red, 'yellow':res_yellow}
for i, (k, v) in enumerate(imgs.items()):
    plt.subplot(2,3, i+1)
    plt.title(k)
    plt.imshow(v[:,:,::-1])
    plt.xticks([]); plt.yticks([])
plt.show()
OpenCV 형상 인식 - OpenCV hyeongsang insig

이와 같이 색상을 이용한 마스킹 방식을 크로마키(chroma key)라고 합니다. 예를 들어, 초록색 배경을 두고 배우가 촬영한 뒤 나중에 초록색 배경은 다른 멋진 배경과 합성하는 방식입니다. 아래는 크로마키의 예시입니다.

# 크로마 키 마스킹과 합성 (chromakey.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

#--① 크로마키 배경 영상과 합성할 배경 영상 읽기
img1 = cv2.imread('../img/man_chromakey.jpg')
img2 = cv2.imread('../img/street.jpg')

#--② ROI 선택을 위한 좌표 계산
height1, width2 = img1.shape[:2]
height2, width2 = img2.shape[:2]
x = (width2 - width2)//2
y = height2 - height1
w = x + width2
h = y + height1

#--③ 크로마키 배경 영상에서 크로마키 영역을 10픽셀 정도로 지정
chromakey = img1[:10, :10, :]
offset = 20

#--④ 크로마키 영역과 영상 전체를 HSV로 변경
hsv_chroma = cv2.cvtColor(chromakey, cv2.COLOR_BGR2HSV)
hsv_img = cv2.cvtColor(img1, cv2.COLOR_BGR2HSV)

#--⑤ 크로마키 영역의 H값에서 offset 만큼 여유를 두어서 범위 지정
# offset 값은 여러차례 시도 후 결정
#chroma_h = hsv_chroma[0]
chroma_h = hsv_chroma[:,:,0]
lower = np.array([chroma_h.min()-offset, 100, 100])
upper = np.array([chroma_h.max()+offset, 255, 255])

#--⑥ 마스크 생성 및 마스킹 후 합성
mask = cv2.inRange(hsv_img, lower, upper)
mask_inv = cv2.bitwise_not(mask)
roi = img2[y:h, x:w]
fg = cv2.bitwise_and(img1, img1, mask=mask_inv)
bg = cv2.bitwise_and(roi, roi, mask=mask)
img2[y:h, x:w] = fg + bg

#--⑦ 결과 출력
cv2.imshow('chromakey', img1)
cv2.imshow('added', img2)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

이렇듯 이미지 합성에는 블렌딩과 마스킹이 필요합니다. 하지만, 블렌딩을 위한 알파 값 선택과 마스킹을 위한 좌표, 색상 선택에는 많은 시간이 소요됩니다. OpenCV에서는 cv2.seamlessClone()이라는 함수가 있는데 이는 두 이미지의 특징을 살려 알아서 합성하는 기능을 합니다.

  • dst = cv2.seamlessClone(src, dst, mask, coords, flags, output)
    src: 입력 이미지, 일반적으로 전경
    dst: 대상 이미지, 일반적으로 배경
    mask: 마스크, src에서 합성하고자 하는 영역은 255, 나머지는 0
    coords: src가 놓이기 원하는 dst의 좌표 (중앙)
    flags: 합성 방식
    output(optional): 합성 결과

flags는 입력 원본을 유지하는 cv2.NORMAL_CLONE과 입력과 대상을 혼합하는 cv2.MIXED_CLONE이 있습니다. 

아래 코드는 손에 꽃 이미지를 합성하는 예제입니다.

# SeamlessClone을 활용한 이미지 합성 (seamlessclone.py)

import cv2
import numpy as np
import matplotlib.pylab as plt
 
#--① 합성 대상 영상 읽기
img1 = cv2.imread("../img/drawing.jpg")
img2= cv2.imread("../img/my_hand.jpg")

#--② 마스크 생성, 합성할 이미지 전체 영역을 255로 셋팅
mask = np.full_like(img1, 255)
 
#--③ 합성 대상 좌표 계산(img2의 중앙)
height, width = img2.shape[:2]
center = (width//2, height//2)
 
#--④ seamlessClone 으로 합성 
normal = cv2.seamlessClone(img1, img2, mask, center, cv2.NORMAL_CLONE)
mixed = cv2.seamlessClone(img1, img2, mask, center, cv2.MIXED_CLONE)

#--⑤ 결과 출력
cv2.imshow('normal', normal)
cv2.imshow('mixed', mixed)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

원본은 꽃 이미지와 손 이미지입니다. 각각의 이미지를 cv2.seamlessClone() 함수를 활용하여 합성한 것입니다. 이 함수를 활용하면 알파 값이나 마스크를 신경 쓰지 않아도 되기 때문에 편합니다. mask는 img1의 전체 영역을 255로 채워서 해당 영역 전부가 합성의 대상임을 표현합니다. 가급적이면 합성하려는 영역을 제외하고는 0으로 채우는 것이 더 좋지만 이번 예제에서는 모든 값을 255로 채웠습니다.

왼쪽이 cv2.NORMAL_CLONE, 오른쪽이 cv2.MIXED_CLONE을 적용한 합성 이미지입니다. cv2.NORMAL_CLONE일 때는 꽃은 선명하지만 주변의 피부가 다소 뭉개진 것을 볼 수 있습니다. 반면, cv2.MIXED_CLONE일 때는 꽃은 다소 흐리지만 주변 피부와 잘 매칭이 되게 합성했습니다.

이번 포스팅에서는 히스토그램에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/04.img_processing

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

히스토그램

히스토그램은 도수 분포표를 그래프로 나타낸 것입니다. 쉽게 말해 무엇이 몇 개 있는지 개수를 세어 놓은 것을 그래프로 나타낸 것을 말합니다. 이미지의 픽셀값을 히스토그램으로 표시하는 것은 이미지를 분석하는 데 도움을 줍니다. 예를 들어 전체 이미지에서 픽셀 값이 1인 픽셀이 몇 개이고, 2인 픽셀이 몇 개이고, 255인 픽셀이 몇 개인지까지 세는 것입니다. 이렇게 함으로써 픽셀들의 색상이나 명암의 분포를 파악할 수 있습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://www.cambridgeincolour.com/tutorials/histograms1.htm

OpenCV에서는 cv2.calcHist()라는 함수를 통해 히스토그램을 구현할 수 있습니다.

  • cv2.calHist(img, channel, mask, histSize, ranges)
    img: 이미지 영상, [img]처럼 리스트로 감싸서 전달
    channel: 분석 처리할 채널, 리스트로 감싸서 전달 - 1 채널: [0], 2 채널: [0, 1], 3 채널: [0, 1, 2]
    mask: 마스크에 지정한 픽셀만 히스토그램 계산, None이면 전체 영역
    histSize: 계급(Bin)의 개수, 채널 개수에 맞게 리스트로 표현 - 1 채널: [256], 2 채널: [256, 256], 3 채널: [256, 256, 256]
    ranges: 각 픽셀이 가질 수 있는 값의 범위, RGB인 경우 [0, 256]

간단한 회색조 이미지의 히스토그램을 그려보겠습니다.

# 회색조 1채널 히스토그램 (histo_gray.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

#--① 이미지 그레이 스케일로 읽기 및 출력
img = cv2.imread('../img/mountain.jpg', cv2.IMREAD_GRAYSCALE)
cv2.imshow('img', img)

#--② 히스토그램 계산 및 그리기
hist = cv2.calcHist([img], [0], None, [256], [0,256])
plt.plot(hist)

print("hist.shape:", hist.shape)  #--③ 히스토그램의 shape (256,1)
print("hist.sum():", hist.sum(), "img.shape:",img.shape) #--④ 히스토그램 총 합계와 이미지의 크기
plt.show()
OpenCV 형상 인식 - OpenCV hyeongsang insig

회색조로 이미지를 읽어 1차원 히스토그램으로 표현해봤습니다. range가 [0, 256]인데 마지막 값은 범위에 포함되지 않으므로 실제 범위는 0부터 255입니다. cv2.calcHist()로 반환한 hist 객체는 plt.plot(hist)을 통해 그래프로 그려줄 수 있습니다.

이제, 회색조가 아닌 색상이 있는 이미지를 3 채널(즉, RGB)로 계산해 히스토그램을 그려보겠습니다.

# 색상 이미지 히스토그램 (histo_rgb.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

#--① 이미지 읽기 및 출력
img = cv2.imread('../img/mountain.jpg')
cv2.imshow('img', img)

#--② 히스토그램 계산 및 그리기
channels = cv2.split(img)
colors = ('b', 'g', 'r')
for (ch, color) in zip (channels, colors):
    hist = cv2.calcHist([ch], [0], None, [256], [0, 256])
    plt.plot(hist, color = color)
plt.show()
OpenCV 형상 인식 - OpenCV hyeongsang insig

cv2.split(img) 함수를 호출하면 R, G, B 채널이 각각 나뉩니다. 즉, img는 색상 이미지이므로 3 채널 이미지인데, cv2.split(img)을 해주면 빨강, 파랑, 초록의 각 1 채널 이미지로 나뉩니다. 히스토그램을 보면 파란색 분포가 큰 것을 알 수 있습니다. 이미지에서 파란 하늘의 영역이 많기 때문입니다. 

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

정규화(Normalization)

이미지 작업에서도 정규화가 필요한 경우가 있습니다. 특정 영역에 몰려 있는 경우 화질을 개선하기도 하고, 이미지 간의 연산 시 서로 조건이 다른 경우 같은 조건으로 만들기도 합니다. OpenCV는 cv2.normalize()라는 함수로 정규화를 제공합니다.

  • dst = cv2.normalize(src, dst, alpha, beta, type_flag)
    src: 정규화 이전의 데이터
    dst: 정규화 이후의 데이터
    alpha: 정규화 구간 1
    beta: 정규화 구간 2, 구간 정규화가 아닌 경우 사용 안 함
    type_flag: 정규화 알고리즘 선택 플래그 상수

type_flag는 alpha와 beta 구간으로 정규화하는 cv2.NORM_MINMAX, 전체 합으로 나누는 cv2.NORM_L1, 단위 벡터로 정규화하는 cv2.NORM_L2, 최댓값으로 나누는 cv2.NORM_INF가 있습니다.

아래는 희미한 이미지를 정규화해 화질을 개선하는 예제입니다.

# 히스토그램 정규화 (histo_normalize.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

#--① 그레이 스케일로 영상 읽기
img = cv2.imread('../img/abnormal.jpg', cv2.IMREAD_GRAYSCALE)

#--② 직접 연산한 정규화
img_f = img.astype(np.float32)
img_norm = ((img_f - img_f.min()) * (255) / (img_f.max() - img_f.min()))
img_norm = img_norm.astype(np.uint8)

#--③ OpenCV API를 이용한 정규화
img_norm2 = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX)

#--④ 히스토그램 계산
hist = cv2.calcHist([img], [0], None, [256], [0, 255])
hist_norm = cv2.calcHist([img_norm], [0], None, [256], [0, 255])
hist_norm2 = cv2.calcHist([img_norm2], [0], None, [256], [0, 255])

cv2.imshow('Before', img)
cv2.imshow('Manual', img_norm)
cv2.imshow('cv2.normalize()', img_norm2)

hists = {'Before' : hist, 'Manual':hist_norm, 'cv2.normalize()':hist_norm2}
for i, (k, v) in enumerate(hists.items()):
    plt.subplot(1,3,i+1)
    plt.title(k)
    plt.plot(v)
plt.show()
OpenCV 형상 인식 - OpenCV hyeongsang insig

맨 왼쪽은 원본 이미지와 그에 대한 히스토그램, 가운데는 공식을 활용하여 수동으로 정규화한 이미지와 그에 대한 히스토그램, 맨 오른쪽은 cv2.normalize() 함수를 활용하여 정규화한 이미지와 그에 대한 히스토그램입니다. 정규화하기 전에는 픽셀 값이 중앙에 몰려있습니다. 하지만 정규화를 적용하니 픽셀 값이 전체적으로 고르게 퍼져서 화질이 개선된 것을 볼 수 있습니다.

평탄화(Equalization)

앞서 설명한 정규화는 분포가 한곳에 집중되어 있는 경우에는 효과적이지만 그 집중된 영역에서 멀리 떨어진 값이 있을 경우에는 효과가 없습니다. 이런 경우 평탄화가 필요합니다. 평탄화는 각각의 값이 전체 분포에 차지하는 비중에 따라 분포를 재분배하므로 명암 대비를 개선하는 데 효과적입니다. 

이미지의 히스토그램이 특정 영역에 너무 집중되어 있으면 명암 대비가 낮아 좋은 이미지라고 할 수 없습니다. 전체 영역에 골고루 분포가 되어 있을 때 좋은 이미지라고 할 수 있습니다. 아래 히스토그램을 보면 좌측처럼 특정 영역에 집중되어 있는 분포를 오른쪽처럼 골고루 분포하도록 하는 작업을 히스토그램 평탄화(Histogram Equalization)라고 합니다. (출처: opencv-python.readthedocs.io)

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: Wikipedia

OpenCV에서 평탄화를 적용하기 위한 함수는 아래와 같습니다.

  • dst = cv2.equalizeHist(src, dst)
    src: 대상 이미지, 8비트 1 채널
    dst(optional): 결과 이미지

전체적으로 어두운 이미지를 평탄화하여 개선해보겠습니다.

# 회색조 이미지에 평탄화 적용 (histo_equalize.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

#--① 대상 영상으로 그레이 스케일로 읽기
img = cv2.imread('../img/yate.jpg', cv2.IMREAD_GRAYSCALE)
rows, cols = img.shape[:2]

#--② 이퀄라이즈 연산을 직접 적용
hist = cv2.calcHist([img], [0], None, [256], [0, 256]) #히스토그램 계산
cdf = hist.cumsum()                                     # 누적 히스토그램 
cdf_m = np.ma.masked_equal(cdf, 0)                      # 0(zero)인 값을 NaN으로 제거
cdf_m = (cdf_m - cdf_m.min()) /(rows * cols) * 255      # 이퀄라이즈 히스토그램 계산
cdf = np.ma.filled(cdf_m,0).astype('uint8')             # NaN을 다시 0으로 환원
print(cdf.shape)
img2 = cdf[img]                                         # 히스토그램을 픽셀로 맵핑

#--③ OpenCV API로 이퀄라이즈 히스토그램 적용
img3 = cv2.equalizeHist(img)

#--④ 이퀄라이즈 결과 히스토그램 계산
hist2 = cv2.calcHist([img2], [0], None, [256], [0, 256])
hist3 = cv2.calcHist([img3], [0], None, [256], [0, 256])

#--⑤ 결과 출력
cv2.imshow('Before', img)
cv2.imshow('Manual', img2)
cv2.imshow('cv2.equalizeHist()', img3)
hists = {'Before':hist, 'Manual':hist2, 'cv2.equalizeHist()':hist3}
for i, (k, v) in enumerate(hists.items()):
    plt.subplot(1,3,i+1)
    plt.title(k)
    plt.plot(v)
plt.show()

결과 이미지의 크기가 커서 이곳에 삽입하는 것은 생략했습니다. 결과를 보시면 명암 대비가 낮은 부둣가 이미지의 화질이 개선된 걸 볼 수 있습니다. 

히스토그램 평탄화는 색상 이미지에도 적용할 수 있습니다. 밝기 값을 개선하기 위해서는 BGR 3개 채널을 모두 평탄화해야 합니다. 하지만 YUV나 HSV를 활용하면 하나의 밝기 채널만 조절하면 됩니다. 아래는 색상 이미지를 BGR에서 YUV 형식으로 변환하여 밝기 채널에 평탄화를 적용한 예제입니다.

# 색상 이미지에 대한 평탄화 적용 (histo_equalize_yuv.py)

import numpy as np, cv2

img = cv2.imread('../img/yate.jpg') #이미지 읽기, BGR 스케일

#--① 컬러 스케일을 BGR에서 YUV로 변경
img_yuv = cv2.cvtColor(img, cv2.COLOR_BGR2YUV) 

#--② YUV 컬러 스케일의 첫번째 채널에 대해서 이퀄라이즈 적용
img_yuv[:,:,0] = cv2.equalizeHist(img_yuv[:,:,0]) 

#--③ 컬러 스케일을 YUV에서 BGR로 변경
img2 = cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR) 

cv2.imshow('Before', img)
cv2.imshow('After', img2)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

평탄화 적용 전(왼쪽 이미지) 보다 적용 후(오른쪽 이미지)가 더 선명합니다. 요트 부분을 보면 밝기가 더 개선되어 화질이 좋아진 것을 볼 수 있습니다.

CLAHE (Contrast Limited Adaptive Histogram Equalization)

지금까지 평탄화를 통해 명암 대비를 개선하여 이미지의 선명도를 높이는 작업에 대해 알아봤습니다. 하지만 평탄화를 하면 이미지의 밝은 부분이 날아가는 현상이 발생합니다. 아래의 이미지를 보겠습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://opencv-python.readthedocs.io

평탄화를 적용하니 조각상의 얼굴이 너무 밝게 바뀌어 경계선을 알아볼 수 없게 되었습니다. 평탄화를 이미지 전체에 적용하면 이런 현상이 자주 발생합니다. 이런 현상을 막기 위해 이미지를 일정한 영역(아래 코드에서 tileGridSize 파라미터)으로 나누어 평탄화를 적용합니다. 그러나 이 방식에도 문제가 있습니다. 일정한 영역 내에서 극단적으로 어둡거나 밝은 부분이 있으면 노이즈가 생겨 원하는 결과를 얻을 수 없게 됩니다. 이 문제를 피하기 위해서 어떤 영역이든 지정된 제한 값(아래 코드에서 clipLimit 파라미터)을 넘으면 그 픽셀은 다른 영역에 균일하게 배분하여 적용합니다. 이러한 평탄화 방식을 CLAHE라고 합니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: Wikipedia

  • clahe = cv2.createCLAHE(clipLimit, tileGridSize)
    clipLimit: 대비(Contrast) 제한 경계 값, default=40.0
    tileGridSize: 영역 크기, default=8 x 8
    clahe: 생성된 CLAHE 객체
  • clahe.apply(src): CLAHE 적용
    src: 입력 이미지

아래는 CLAHE를 적용한 예제입니다.

# CLAHE 적용 (histo_clahe.py)

import cv2
import numpy as np
import matplotlib.pylab as plt

#--①이미지 읽어서 YUV 컬러스페이스로 변경
img = cv2.imread('../img/bright.jpg')
img_yuv = cv2.cvtColor(img, cv2.COLOR_BGR2YUV)

#--② 밝기 채널에 대해서 이퀄라이즈 적용
img_eq = img_yuv.copy()
img_eq[:,:,0] = cv2.equalizeHist(img_eq[:,:,0])
img_eq = cv2.cvtColor(img_eq, cv2.COLOR_YUV2BGR)

#--③ 밝기 채널에 대해서 CLAHE 적용
img_clahe = img_yuv.copy()
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8,8)) #CLAHE 생성
img_clahe[:,:,0] = clahe.apply(img_clahe[:,:,0])           #CLAHE 적용
img_clahe = cv2.cvtColor(img_clahe, cv2.COLOR_YUV2BGR)

#--④ 결과 출력
cv2.imshow('Before', img)
cv2.imshow('CLAHE', img_clahe)
cv2.imshow('equalizeHist', img_eq)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

왼쪽이 원본 사진, 가운데가 CLAHE를 적용한 사진, 오른쪽이 단순한 평탄화를 적용한 사진입니다. 단순한 평탄화를 적용할 땐 밝은 부분은 날아가는 현상이 발생합니다. 하지만 CLAHE를 적용할 땐 이러한 현상이 개선되었습니다.

CLAHE를 적용하기 위해서는 CLAHE 객체를 먼저 생성하고, 생성된 CLAHE 객체에 apply() 함수를 적용해줘야 합니다. 아래의 코드가 CLAHE의 핵심코드입니다.

clahe = cv2.createCLAHE()
img2 = clahe.apply(img)

이상으로 이미지 히스토그램, 정규화, 평탄화, CLAHE에 대해 알아봤습니다.

이번 포스팅에서는 2차원 히스토그램과 역투영에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/04.img_processing

2차원 히스토그램 (2D Histogram)

1차원 히스토그램은 이미지 안에 픽셀이 각각 몇 개인지를 표현합니다. 반면, 2차원 히스토그램은 축이 2개이고, 각 축이 만나는 지점의 개수를 표현합니다. 바로 예를 들어보겠습니다.

# 2D 히스토그램 (histo_2d.py)

import cv2
import matplotlib.pylab as plt

plt.style.use('classic')            # --①컬러 스타일을 1.x 스타일로 사용
img = cv2.imread('../img/mountain.jpg')

plt.subplot(131)
hist = cv2.calcHist([img], [0,1], None, [32,32], [0,256,0,256]) #--②
p = plt.imshow(hist)                                            #--③
plt.title('Blue and Green')                                     #--④
plt.colorbar(p)                                                 #--⑤


plt.subplot(132)
hist = cv2.calcHist([img], [1,2], None, [32,32], [0,256,0,256]) #--⑥
p = plt.imshow(hist)
plt.title('Green and Red')
plt.colorbar(p)

plt.subplot(133)
hist = cv2.calcHist([img], [0,2], None, [32,32], [0,256,0,256]) #--⑦
p = plt.imshow(hist)
plt.title('Blue and Red')
plt.colorbar(p)

plt.show()
OpenCV 형상 인식 - OpenCV hyeongsang insig

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

역투영(Back Projection)

역투영이란 관심 영역의 히스토그램과 유사한 히스토그램을 갖는 영역을 찾아내는 기법입니다. 역투영을 활용하면 이미지 내에서 특정 물체나 배경을 분리할 수 있습니다. 아래 이미지를 보겠습니다. 이미지 내에서 잔디만 분리하고 싶은 경우 잔디에 해당하는 관심 영역(ROI, region of interest)을 지정하고 역투영을 적용합니다. 그러면 잔디에 해당하는 부분은 흰색으로, 잔디가 아닌 부분은 검은색으로 서로 분리가 됩니다. 다만 이 방법은 색상을 기준으로 분리하기 때문에 잔디와 비슷한 색상을 가진 다른 물체가 있는 경우 성능이 떨어집니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: docs.opencv.org

아래는 역투영을 활용하여 이미지를 분리하는 예제 코드입니다.

# 마우스로 선택한 영역의 물체 분리하기 (histo_backproject.py)

import cv2
import numpy as np
import matplotlib.pyplot as plt

win_name = 'back_projection'
img = cv2.imread('../img/pump_horse.jpg')
hsv_img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
draw = img.copy()

#--⑤ 역투영된 결과를 마스킹해서 결과를 출력하는 공통함수
def masking(bp, win_name):
    disc = cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(5,5))
    cv2.filter2D(bp,-1,disc,bp)
    _, mask = cv2.threshold(bp, 1, 255, cv2.THRESH_BINARY)
    result = cv2.bitwise_and(img, img, mask=mask)
    cv2.imshow(win_name, result)

#--⑥ 직접 구현한 역투영 함수
def backProject_manual(hist_roi):
    #--⑦ 전체 영상에 대한 H,S 히스토그램 계산
    hist_img = cv2.calcHist([hsv_img], [0,1], None,[180,256], [0,180,0,256])
    #--⑧ 선택영역과 전체 영상에 대한 히스토그램 그램 비율계산
    hist_rate = hist_roi/ (hist_img + 1)
    #--⑨ 비율에 맞는 픽셀 값 매핑
    h,s,v = cv2.split(hsv_img)
    bp = hist_rate[h.ravel(), s.ravel()]
    # 비율은 1을 넘어서는 안되기 때문에 1을 넘는 수는 1을 갖게 함
    bp = np.minimum(bp, 1)
    # 1차원 배열을 원래의 shape로 변환
    bp = bp.reshape(hsv_img.shape[:2])
    cv2.normalize(bp,bp, 0, 255, cv2.NORM_MINMAX)
    bp = bp.astype(np.uint8)
    #--⑩ 역 투영 결과로 마스킹해서 결과 출력
    masking(bp,'result_manual')
 
# OpenCV API로 구현한 함수 ---⑪ 
def backProject_cv(hist_roi):
    # 역투영 함수 호출 ---⑫
    bp = cv2.calcBackProject([hsv_img], [0, 1], hist_roi,  [0, 180, 0, 256], 1)
    # 역 투영 결과로 마스킹해서 결과 출력 ---⑬ 
    masking(bp,'result_cv')

# ROI 선택 ---①
(x,y,w,h) = cv2.selectROI(win_name, img, False)
if w > 0 and h > 0:
    roi = draw[y:y+h, x:x+w]
    # 빨간 사각형으로 ROI 영역 표시
    cv2.rectangle(draw, (x, y), (x+w, y+h), (0,0,255), 2)
    #--② 선택한 ROI를 HSV 컬러 스페이스로 변경
    hsv_roi = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
    #--③ H,S 채널에 대한 히스토그램 계산
    hist_roi = cv2.calcHist([hsv_roi],[0, 1], None, [180, 256], [0, 180, 0, 256] )
    #--④ ROI의 히스토그램을 매뉴얼 구현함수와 OpenCV 이용하는 함수에 각각 전달
    backProject_manual(hist_roi)
    backProject_cv(hist_roi)
cv2.imshow(win_name, draw)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

마우스로 관심영역을 선택한 뒤 엔터를 누르면 관심 영역에 해당하는 물체만 추출됩니다. 관심 영역을 HSV 형식으로 변경한 뒤 H, S 채널에 대한 2차원 히스토그램 만듭니다. 이렇게 만든 히스토그램을 직접 구현한 함수와 OpenCV 함수의 파라미터로 전달합니다.

먼저, backProject_manual(hist_roi) 함수를 살펴보겠습니다. 이는 역투영을 직접 구현한 함수입니다. 파라미터로는 관심 영역 히스토그램이 전달되었습니다. 이 히스토그램을 전체 이미지의 히스토그램으로 나누어 비율을 구합니다. 이때 분모에 1을 더하는 이유는 분모가 0이 되는 것을 막기 위해서입니다. 비율을 구한다는 것은 관심 영역과 비슷한 색상 분포를 갖는 히스토그램은 1에 가까운 값을 갖고, 그렇지 않은 히스토그램은 0에 가까운 값을 갖게 되므로 마스킹에 사용하기 좋다는 뜻입니다. 그다음 이렇게 구한 비율을 원래 이미지의 H, S 픽셀 값에 매핑합니다.

여기서 bp = hist_rate[h.ravel(), s.ravel()] 코드가 헷갈릴 수도 있는데 아래 코드를 보면 이해가 쉬울 겁니다.

>>> v = np.arange(6).reshape(2,3)
>>> v
array([[0, 1, 2,],
	  [3, 4, 5]])
   
>>> row = np.array([1,1,1,0,0,0])
>>> col = np.array([0,1,2,0,1,2])
>>> v[row, col]
arrya([3, 4, 5, 0, 1, 2])

hist_rate는 히스토그램 비율을 값으로 가지고 있고, h와 s는 실제 이미지의 각 픽셀입니다. 따라서 h와 s가 교차되는 지점의 비율을 그 픽셀 값으로 하는 1차원 배열을 얻게 됩니다.  이렇게 얻은 값은 비율이기 때문에 1을 넘어서는 안 됩니다. 따라서 1을 넘는 모든 수는 1로 변환합니다. 마지막으로 정규화를 하고 타입 변경까지 하면 끝입니다. 

하지만 이 모든 작업을 OpenCV에서는 아래와 같은 함수로 제공합니다.

  • cv2.calcBackProject(img, channel, hist, ranges, scale)
    img: 입력 이미지, [img]처럼 리스트로 감싸서 사용
    channel: 처리할 채널, 리스트로 감싸서 사용
    hist: 역투영에 사용할 히스토그램
    ranges: 각 픽셀이 가질 수 있는 값의 범위
    scale: 결과에 적용할 배율 계수

cv2.calcBackProject의 세 번째 파라미터인 hist에는 역투영에 사용할 히스토그램을 전달하면 됩니다. 마지막으로 masking() 함수는 앞서 다룬 스레시홀딩과 마스킹을 활용하여 결과를 출력하는 기능을 합니다. cv2.getStructuringElement()와 cv2.filter2D()는 마스크의 표면을 부드럽게 해주는 역할을 합니다.

역투영의 장단점

역투영은 알파 채널이나 크로마 키 같은 것이 없어도 복잡한 모양의 사물을 분리할 수 있다는 장점이 있습니다. 하지만 역투영은 히스토그램을 기반으로 관심 영역의 색상과 비슷한 물체를 추출하므로, 관심 영역의 색상과 비슷한 다른 물체가 뒤섞여 있을 때는 효과가 떨어질 수 있습니다.

이번 포스팅에서는 여러 가지 실습을 해보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/04.img_processing

이미지 유사도 비교 실습

이전 포스팅에서 히스토그램은 이미지의 픽셀 값의 분포를 나타낸다고 배웠습니다. 픽셀 값의 분포가 서로 비슷하다면 유사한 이미지일 확률이 높고, 분포가 서로 다르다면 서로 다른 이미지일 확률이 높습니다. 이러한 사실을 이용하여 이미지의 유사도를 측정할 수 있습니다. 즉, 두 이미지의 히스토그램을 비교하면 되는 것입니다. OpenCV는 히스토그램을 비교하여 두 이미지가 얼마나 유사한지 판단해주는 함수를 제공합니다.

  • cv2.compareHist(hist1, hist2, method)
    hist1, hist2: 비교할 두 개의 히스토그램, 크기와 차원이 같아야 함
    method: 비교 알고리즘

method 파라미터에는 아래와 같은 값들이 있습니다.
cv2.HISTCMP_CORREL: 상관관계 (1: 완전 일치, -1: 완전 불일치, 0: 무관계)
cv2.HISTCMP_CHISQR: 카이제곱 (0: 완전 일치, 무한대: 완전 불일치)
cv2.HISTCMP_INTERSECT: 교차 (1: 완전 일치, 0: 완전 불일치 - 1로 정규화한 경우)

아래 예제는 서로 다른 각도에서 찍은 로봇 태권 V 장난감 이미지와 코주부 박사 장난감 이미지를 비교하는 코드입니다.

# 히스토그램 비교 (histo_compare.py)

import cv2, numpy as np
import matplotlib.pylab as plt

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/taekwonv2.jpg')
img3 = cv2.imread('../img/taekwonv3.jpg')
img4 = cv2.imread('../img/dr_ochanomizu.jpg')

cv2.imshow('query', img1)
imgs = [img1, img2, img3, img4]
hists = []
for i, img in enumerate(imgs) :
    plt.subplot(1,len(imgs),i+1)
    plt.title('img%d'% (i+1))
    plt.axis('off') 
    plt.imshow(img[:,:,::-1])
    #---① 각 이미지를 HSV로 변환
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    #---② H,S 채널에 대한 히스토그램 계산
    hist = cv2.calcHist([hsv], [0,1], None, [180,256], [0,180,0, 256])
    #---③ 0~1로 정규화
    cv2.normalize(hist, hist, 0, 1, cv2.NORM_MINMAX)
    hists.append(hist)


query = hists[0]
methods = {'CORREL' :cv2.HISTCMP_CORREL, 'CHISQR':cv2.HISTCMP_CHISQR, 
           'INTERSECT':cv2.HISTCMP_INTERSECT,
           'BHATTACHARYYA':cv2.HISTCMP_BHATTACHARYYA}
for j, (name, flag) in enumerate(methods.items()):
    print('%-10s'%name, end='\t')
    for i, (hist, img) in enumerate(zip(hists, imgs)):
        #---④ 각 메서드에 따라 img1과 각 이미지의 히스토그램 비교
        ret = cv2.compareHist(query, hist, flag)
        if flag == cv2.HISTCMP_INTERSECT: #교차 분석인 경우 
            ret = ret/np.sum(query)        #비교대상으로 나누어 1로 정규화
        print("img%d:%7.2f"% (i+1 , ret), end='\t')
    print()
plt.show()
OpenCV 형상 인식 - OpenCV hyeongsang insig

각 이미지를 HSV 형식으로 변환한 뒤 H, V에 대하여 2차원 히스토그램을 계산해 정규화했습니다. 이렇게 정규화한 이미지를 원본 이미지와 비교합니다. 특이하게, cv2.HISTCMP_INTERSECT인 경우 원본 히스토그램으로 나누어주면 정규화가 되어 결과를 판별하기가 쉬워집니다. method 알고리즘 별 결과는 다음과 같습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

원본 이미지(위 그림에서 맨 왼쪽 큰 이미지)는 img1과 완전 일치하며, img4와 가장 유사하지 않다는 것을 확인할 수 있습니다.

사람 얼굴과 해골 얼굴 합성 실습

연습용으로 아래 사람 얼굴과 해골 얼굴을 반반씩 합성해보겠습니다. 

OpenCV 형상 인식 - OpenCV hyeongsang insig

아래와 같이 그냥 반반씩 합치면 경계선이 뚜렷해 어색합니다. 알파 값을 조절하며 자연스럽게 이미지 합성을 해보겠습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig
# 사람 얼굴과 해골 합성하기 (workshop_two_face.py)

import cv2
import numpy as np

# 영상의 15%를 알파 블렌딩의 범위로 지정
alpha_width_rate = 15

# 합성할 두 영상 읽기
img_face = cv2.imread('../img/man_face.jpg')
img_skull = cv2.imread('../img/skull.jpg')

# 입력 영상과 같은 크기의 결과 영상 준비
img_comp = np.zeros_like(img_face)

# 연산에 필요한 좌표 계산
height, width = img_face.shape[:2]
middle = width//2                             # 영상의 중앙 좌표
alpha_width = width * alpha_width_rate // 100 # 알파 블렌딩 범위
start = middle - alpha_width//2               # 알파 블렌딩 시작 지점
step = 100/alpha_width                        # 알파 값 간격

# 입력 영상의 절반씩 복사해서 결과 영상에 합성
img_comp[:, :middle, : ] = img_face[:, :middle, :].copy()
img_comp[:, middle:, :] = img_skull[:, middle:, :].copy()
cv2.imshow('half', img_comp)

# 알파 값을 바꾸면서 알파 블렌딩 적용
for i in range(alpha_width+1 ):
    alpha = (100 - step * i) / 100  # 증감 간격에 따른 알파 값 (1~0)
    beta = 1 - alpha                # 베타 값 (0~1)
    # 알파 블렌딩 적용
    img_comp[:, start+i] = img_face[:, start+i] * \
                                alpha + img_skull[:, start+i] * beta
    print(i, alpha, beta)
    
cv2.imshow('half skull', img_comp)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

자연스럽게 반반씩 합성이 되었습니다. 이 예제에서 핵심 코드는 img_comp[:, start+i] = img_face[:, start+i] * alpha + img_skull[:, start+i] * beta 부분입니다. 사람 얼굴 이미지의 반쪽과 해골 이미지의 반쪽을 알파 값을 조정하며 합성하는 부분이기 때문입니다.

움직임 감지 CCTV 만들기 실습

모션 감지를 하기 위해서는 어떤 알고리즘을 써야 할까요? 단순히 전후 영상의 차이를 구하면 안 됩니다. 고정되어 있는 물체라도 미세한 움직임은 있을 수 있기 때문입니다. 따라서 세 개의 프레임 a, b, c를 순차적으로 얻어서 a와 b의 차이 그리고 b와 c의 차이가 모두 발견되는 경우에 한해서 움직임이 있는 것으로 판단해야 합니다. 아래 코드는 이런 방식으로 움직임을 감지하는 카메라를 실행시킵니다.

# 모션 감지 CCTV (workshop_cctv_motion_sensor.py)

import cv2
import numpy as np

# 감도 설정(카메라 품질에 따라 조정 필요)
thresh = 25    # 달라진 픽셀 값 기준치 설정
max_diff = 5   # 달라진 픽셀 갯수 기준치 설정

# 카메라 캡션 장치 준비
a, b, c = None, None, None
cap = cv2.VideoCapture(0)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 480)      # 프레임 폭을 480으로 설정 
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 320)     # 프레임 높이를 320으로 설정

if cap.isOpened():
    ret, a = cap.read()         # a 프레임 읽기
    ret, b = cap.read()         # b 프레임 읽기

    while ret:
        ret, c = cap.read()     # c 프레임 읽기
        draw = c.copy()         # 출력 영상에 사용할 복제본
        if not ret:
            break
        
        # 3개의 영상을 그레이 스케일로 변경
        a_gray = cv2.cvtColor(a, cv2.COLOR_BGR2GRAY)
        b_gray = cv2.cvtColor(b, cv2.COLOR_BGR2GRAY)
        c_gray = cv2.cvtColor(c, cv2.COLOR_BGR2GRAY)

        # a-b, b-c 절대 값 차 구하기 
        diff1 = cv2.absdiff(a_gray, b_gray)
        diff2 = cv2.absdiff(b_gray, c_gray)

        # 스레시홀드로 기준치 이내의 차이는 무시
        ret, diff1_t = cv2.threshold(diff1, thresh, 255, cv2.THRESH_BINARY)
        ret, diff2_t = cv2.threshold(diff2, thresh, 255, cv2.THRESH_BINARY)

        # 두 차이에 대해서 AND 연산, 두 영상의 차이가 모두 발견된 경우
        diff = cv2.bitwise_and(diff1_t, diff2_t)

        # 열림 연산으로 노이즈 제거 ---①
        k = cv2.getStructuringElement(cv2.MORPH_CROSS, (3,3))
        diff = cv2.morphologyEx(diff, cv2.MORPH_OPEN, k)

        # 차이가 발생한 픽셀이 갯수 판단 후 사각형 그리기
        diff_cnt = cv2.countNonZero(diff)
        if diff_cnt > max_diff:
            nzero = np.nonzero(diff)  # 0이 아닌 픽셀의 좌표 얻기(y[...], x[...])
            cv2.rectangle(draw, (min(nzero[1]), min(nzero[0])), \
                                (max(nzero[1]), max(nzero[0])), (0,255,0), 2)
            cv2.putText(draw, "Motion Detected", (10,30), \
                                cv2.FONT_HERSHEY_DUPLEX, 0.5, (0,0,255))
        
        # 컬러 스케일 영상과 스레시홀드 영상을 통합해서 출력
        stacked = np.hstack((draw, cv2.cvtColor(diff, cv2.COLOR_GRAY2BGR)))
        cv2.imshow('motion sensor',stacked )

        # 다음 비교를 위해 영상 순서 정리
        a = b
        b = c
        
        if cv2.waitKey(1) & 0xFF == 27:
            break                
OpenCV 형상 인식 - OpenCV hyeongsang insig

PC에 내장 카메라가 있는 경우 코드를 실행하면 두 영상이 뜰 겁니다. 하나는 내 모습을 보여주는 영상이고, 다른 하나는 움직임을 감지하는 영상입니다. 몸을 좌우로 움직이면 움직이는 부분을 감지하여 표시합니다. 

아래의 두 코드는 미세한 노이즈를 제거하는 기능을 합니다. 

k = cv2.getStructuringElement(cv2.MORPH_CROSS, (3,3))
diff = cv2.morphologyEx(diff, cv2.MORPH_OPEN, k)

이상으로 지금까지 배운 OpenCV 코드를 기반으로 3가지 실습을 해봤습니다.

이번 포스팅에서는 이미지를 이동, 확대, 축소, 회전하는 방법에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/05.geometric_transform

이미지 이동

이미지를 이동하는 방법은 간단합니다. 원래 있던 좌표에 이동시키려는 거리만큼 더하면 됩니다. 

x_new = x_old + d₁y_new = y_old + d₂

위 방정식을 행렬식으로 표현하면 아래와 같습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

행렬식을 다시 풀어서 써보겠습니다.

x_new = x_old + d₁ = 1*x_old + 0*y_old + d₁
y_new = y_old + d₂ = 0*x_old + 1*y_old + d₂

보시는 바와 같이 이미지의 좌표를 이동하는 변환 행렬은 2 x 3 행렬입니다. 변환 행렬이란 어떤 좌표를 선형 변환(linear tranformations) 해주는 행렬을 뜻합니다. 쉽게 말해서 어떤 좌표를 다른 좌표로 이동시켜주는 행렬이라는 뜻입니다. 즉, 어떤 좌표에 변환 행렬을 곱해주면 다른 좌표가 구해지는 행렬입니다. 아래는 이미지 이동에 대한 변환 행렬입니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

OpenCV에서는 변환행렬을 이용해서 이미지를 변환하는 기능을 하는 cv2.warpAffine() 함수를 제공합니다.

  • dst = cv2.warpAffine(src, matrix, dsize, dst, flags, borderMode, borderValue)
    src: 원본 이미지, numpy 배열
    matrix: 2 x 3 변환행렬, dtype=float32
    dsize: 결과 이미지의 크기, (width, height)
    flags(optional): 보간법 알고리즘 플래그
    borderMode(optional): 외곽 영역 보정 플래그
    borderValue(optional): cv2.BORDER_CONSTANT 외곽 영역 보정 플래그일 경우 사용할 색상 값 (default=0)
    dst: 결과 이미지

flags의 값은 아래와 같습니다.
cv2.INTER_LINEAR: default 값, 인접한 4개 픽셀 값에 거리 가중치 사용
cv2.INTER_NEAREST: 가장 가까운 픽셀 값 사용
cv2.INTER_AREA: 픽셀 영역 관계를 이용한 재샘플링
cv2.INTER_CUBIC: 인정합 16개 픽셀 값에 거리 가중치 사용

borderMode의 값은 아래와 같습니다.cv2.BORDER_CONSTANT: 고정 색상 값cv2.BORDER_REPLICATE: 가장자리 복제cv2.BORDER_WRAP: 반복cv2.BORDER_REFLECT: 반사

파라미터가 많아서 헷갈리실 수도 있겠습니다. 원본 이미지인 src를 변환 행렬 matrix에 따라 변환하는 함수라고 보시면 됩니다. 이때 결과 이미지의 크기를 나타내는 파라미터는 dsize입니다. 

아래 코드는 이미지를 평행 이동하는 예제입니다.

# 평행 이동 (translate.py)

import cv2
import numpy as np

img = cv2.imread('../img/fish.jpg')
rows,cols = img.shape[0:2]  # 영상의 크기

dx, dy = 100, 50            # 이동할 픽셀 거리

# ---① 변환 행렬 생성 
mtrx = np.float32([[1, 0, dx],
                   [0, 1, dy]])  
# ---② 단순 이동
dst = cv2.warpAffine(img, mtrx, (cols+dx, rows+dy))   

# ---③ 탈락된 외곽 픽셀을 파랑색으로 보정
dst2 = cv2.warpAffine(img, mtrx, (cols+dx, rows+dy), None, \
                        cv2.INTER_LINEAR, cv2.BORDER_CONSTANT, (255,0,0) )

# ---④ 탈락된 외곽 픽셀을 원본을 반사 시켜서 보정
dst3 = cv2.warpAffine(img, mtrx, (cols+dx, rows+dy), None, \
                                cv2.INTER_LINEAR, cv2.BORDER_REFLECT)

cv2.imshow('original', img)
cv2.imshow('trans',dst)
cv2.imshow('BORDER_CONSTATNT', dst2)
cv2.imshow('BORDER_FEFLECT', dst3)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

물고기 이미지를 가로(x) 방향으로 100픽셀, 세로(y) 방향으로 50픽셀 평행 이동시켰습니다. 가로의 양의 방향은 오른쪽이고, 세로의 양의 방향은 아래쪽입니다. 그리고 평행 이동을 시키면 기존 영역은 이미지가 잘립니다. 잘리는 영역을 어떻게 보정할지를 borderMode 파라미터로 조정할 수 있습니다. 사실 이 코드에서 외곽 영역 이외에는 픽셀의 탈락이 발생하지 않으므로 flags 파라미터는 무의미합니다.

이미지 확대 및 축소

이미지를 일정 비율로 확대 및 축소하는 방법은 아래와 같습니다. 기존의 좌표에 특정한 값을 곱하면 됩니다.

x_new = a₁ * x_old
y_new = a₂ * y_old

이를 다시 풀어쓰면 아래와 같습니다.

x_new = a₁ * x_old = a₁ * x_old + 0 * y_old + 0 * 1
y_new = a₂ * y_old = 0 * x_old + a₂ * y_old + 0 * 1

OpenCV 형상 인식 - OpenCV hyeongsang insig

변환 행렬은 평행 이동할 때와 마찬가지로 2 x 3 행렬입니다. 2 x 2 행렬로 나타낼 수 있는데 굳이 2 x 3 행렬로 표현한 이유는 cv2.warpAffine() 함수는 변환 행렬이 2 x 3 행렬이 아니면 오류를 내기 때문입니다. 기하학적 변환은 이미지 확대 및 축소뿐만 아니라 평행 이동도 있습니다. 두 변환을 같이 하기 위해 2 x 3 행렬로 맞춘 것입니다.

아래는 변환 행렬을 이용해서 이미지를 확대하고 축소하는 예제 코드입니다. 이미지 평행 이동과 마찬가지로 cv2.warpAffine() 함수를 사용했습니다.

# 행렬을 이용한 이미지 확대 및 축소 (scale_matrix.py)

import cv2
import numpy as np

img = cv2.imread('../img/fish.jpg')
height, width = img.shape[:2]

# --① 0.5배 축소 변환 행렬
m_small = np.float32([[0.5, 0, 0],
                       [0, 0.5,0]])  
# --② 2배 확대 변환 행렬
m_big = np.float32([[2, 0, 0],
                     [0, 2, 0]])  

# --③ 보간법 적용 없이 확대 축소
dst1 = cv2.warpAffine(img, m_small, (int(height*0.5), int(width*0.5)))
dst2 = cv2.warpAffine(img, m_big, (int(height*2), int(width*2)))

# --④ 보간법 적용한 확대 축소
dst3 = cv2.warpAffine(img, m_small, (int(height*0.5), int(width*0.5)), \
                        None, cv2.INTER_AREA)
dst4 = cv2.warpAffine(img, m_big, (int(height*2), int(width*2)), \
                        None, cv2.INTER_CUBIC)

# 결과 출력
cv2.imshow("original", img)
cv2.imshow("small", dst1)
cv2.imshow("big", dst2)
cv2.imshow("small INTER_AREA", dst3)
cv2.imshow("big INTER_CUBIC", dst4)
cv2.waitKey(0)
cv2.destroyAllWindows()

실행을 하면 원본 이미지가 2배 확대된 이미지와 1/2로 축소된 이미지를 보여줍니다. 일반적으로 보간법 파라미터로는 축소에는 cv2.INTER_AREA를 쓰고, 확대에는 cv2.INTER_CUBIC, cv2.INTER_LINEAR를 씁니다. 

변환 행렬을 쓰지 않고도 확대 및 축소를 할 수 있습니다. cv2.resize() 함수를 사용하면 됩니다.

  • cv2.resize(src, dsize, dst, fx, fy, interpolation)
    src: 입력 원본 이미지
    dsize: 출력 영상 크기(확대/축소 목표 크기, (width, height)형식), 생략하면 fx, fy 배율을 적용
    fx, fy: 크기 배율, dsize가 주어지면 dsize를 적용함
    interpolation: 보간법 알고리즘 선택 플래그 (cv2.warpAffine()과 동일)
    dst: 결과 이미지

cv2.resize() 함수를 사용하면 확대/축소를 몇 픽셀로 할지 혹은 어떤 배율로 할지 선택할 수 있습니다. dsize는 확대/축소를 원하는 목표 이미지의 크기이며, fx, fy는 변경할 배율입니다. 예를 들어 fx = 2, fy = 0.5이면 x축으로 2배, y축으로 0.5배로 스케일링한다는 뜻입니다. 아래는 cv2.resize()를 적용하여 이미지를 확대 및 축소하는 코드입니다.

# cv2.reize()로 이미지 확대 및 축소 (scale_resize.py)

import cv2
import numpy as np

img = cv2.imread('../img/fish.jpg')
height, width = img.shape[:2]

#--① 크기 지정으로 축소
#dst1 = cv2.resize(img, (int(width*0.5), int(height*0.5)),\
#                        None, 0, 0, cv2.INTER_AREA)
dst1 = cv2.resize(img, (int(width*0.5), int(height*0.5)), \
                         interpolation=cv2.INTER_AREA)

#--② 배율 지정으로 확대
dst2 = cv2.resize(img, None,  None, 2, 2, cv2.INTER_CUBIC)
#--③ 결과 출력
cv2.imshow("original", img)
cv2.imshow("small", dst1)
cv2.imshow("big", dst2)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

위 코드는 원본 이미지를 2배, 1/2배로 확대 및 축소합니다. dst1은 원하는 결과의 크기를 픽셀 값으로 직접 지정해준 것이고, dst2는 배율을 지정하여 이미지를 스케일링한 것입니다. cv2.resize() 함수는 변환 행렬을 이용하여 이미지 스케일링을 하는 것보다 더 간결하고 쉽습니다.

이미지 회전

이미지 회전을 위한 변환 행렬식은 아래와 같습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://slidesplayer.org/slide/14096571/

변환 행렬을 이용하여 이미지를 회전해보겠습니다.

# 변환행렬을 이용한 이미지 회전 (rotate_martix.py)

import cv2
import numpy as np

img = cv2.imread('../img/fish.jpg')
rows,cols = img.shape[0:2]

# ---① 라디안 각도 계산(60진법을 호도법으로 변경)
d45 = 45.0 * np.pi / 180    # 45도
d90 = 90.0 * np.pi / 180    # 90도

# ---② 회전을 위한 변환 행렬 생성
m45 = np.float32( [[ np.cos(d45), -1* np.sin(d45), rows//2],
                    [np.sin(d45), np.cos(d45), -1*cols//4]])
m90 = np.float32( [[ np.cos(d90), -1* np.sin(d90), rows],
                    [np.sin(d90), np.cos(d90), 0]])

# ---③ 회전 변환 행렬 적용
r45 = cv2.warpAffine(img,m45,(cols,rows))
r90 = cv2.warpAffine(img,m90,(rows,cols))

# ---④ 결과 출력
cv2.imshow("origin", img)
cv2.imshow("45", r45)
cv2.imshow("90", r90)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

변환 행렬에 사용할 회전 각은 60진법에서 라디안(radian)으로 변경해야 합니다. 1 라디안(radian) = (180/π)도 입니다.  따라서 1도 = (π/180) 라디안입니다. 따라서 45도 = (45 * π)/180라는 것을 알 수 있습니다. 또한, 변환 행렬의 마지막에 0이 아닌 rows//2, -1*cols//4, rows를 사용했습니다. 영상의 회전 기준 축이 좌측 상단이므로 회전한 영상은 프레임 바깥으로 벗어나게 됩니다. 프레임 바깥으로 벗어난 이미지를 프레임 안쪽으로 이동시키기 위해  rows//2, -1*cols//4, rows를 사용한 것입니다. 회전을 한 뒤 평행 이동을 한 것인데, 이를 하나의 변환 행렬로 처리한 것입니다. 

하지만 이렇게 변환 행렬을 직접 구하는 것은 복잡한 작업입니다. 이동시킬 좌표의 크기까지 고려해야 하기 때문입니다. OpenCV는 간단하게 변환행렬을 생성할 수 있게 아래와 같은 함수를 제공합니다.

  • mtrx = cv2.getRotationMatrix2D(center, angle, scale)center: 회전축 중심 좌표 (x, y)angle: 회전할 각도, 60진법scale: 확대 및 축소비율

이 함수를 쓰면 손쉽게 회전을 위한 변환 행렬을 구할 수 있습니다.

# OpenCv로 회전 변환행렬 구하기 (rotate_getmatrix.py)

import cv2

img = cv2.imread('../img/fish.jpg')
rows,cols = img.shape[0:2]

#---① 회전을 위한 변환 행렬 구하기
# 회전축:중앙, 각도:45, 배율:0.5
m45 = cv2.getRotationMatrix2D((cols/2,rows/2),45,0.5) 
# 회전축:중앙, 각도:90, 배율:1.5
m90 = cv2.getRotationMatrix2D((cols/2,rows/2),90,1.5) 

#---② 변환 행렬 적용
img45 = cv2.warpAffine(img, m45,(cols, rows))
img90 = cv2.warpAffine(img, m90,(cols, rows))

#---③ 결과 출력
cv2.imshow('origin',img)
cv2.imshow("45", img45)
cv2.imshow("90", img90)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

cv2.getRotationMatrix2D() 함수를 이용해서 이미지를 회전해봤습니다. 

이상으로 OpenCV 함수를 이용해서 이미지를 이동하고 스케일링하고 회전하는 방법에 대해 알아봤습니다.

이번 포스팅에서는 이미지를 뒤트는 방법에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/05.geometric_transform

이전 포스팅에서는 이미지를 이동, 확대/축소, 회전하는 방법에 대해서 알아봤습니다. 이동, 확대/축소, 회전을 한 후에는 이미지의 모양이 그대로 유지되지만, 이번 포스팅에서 배울 이미지 뒤틀기(wraping)를 하면 기존 모양과 달라집니다. 이미지 뒤틀기에는 크게 두 가지가 있는데 어핀 변환과 원근 변환이 있습니다.

어핀 변환(Affine Transform)

어핀 변환은 뒤틀기 방법 중 하나입니다. 말이 어려워 보이지만 아래 예제를 보면 어떤 변환인지 쉽게 이해가 될 겁니다.

  • martix = cv2.getAffineTransform(pts1, pts2)pts1: 변환 전 영상의 좌표 3개, 3 x 2 배열pts2: 변환 후 영상의 좌표 3개, 3 x 2 배열matrix: 변환 행렬 반환, 2 x 3 행렬

이 함수는 3개의 좌표인 pts1이 pts2로 위치가 변한 만큼 이미지를 뒤트는 기능을 제공합니다. 

# 어핀 변환 (getAffine.py)

import cv2
import numpy as np
from matplotlib import pyplot as plt

file_name = '../img/fish.jpg'
img = cv2.imread(file_name)
rows, cols = img.shape[:2]

# ---① 변환 전, 후 각 3개의 좌표 생성
pts1 = np.float32([[100, 50], [200, 50], [100, 200]])
pts2 = np.float32([[80, 70], [210, 60], [250, 120]])

# ---② 변환 전 좌표를 이미지에 표시
cv2.circle(img, (100,50), 5, (255,0), -1)
cv2.circle(img, (200,50), 5, (0,255,0), -1)
cv2.circle(img, (100,200), 5, (0,0,255), -1)

#---③ 짝지은 3개의 좌표로 변환 행렬 계산
mtrx = cv2.getAffineTransform(pts1, pts2)
#---④ 어핀 변환 적용
dst = cv2.warpAffine(img, mtrx, (int(cols*1.5), rows))

#---⑤ 결과 출력
cv2.imshow('origin',img)
cv2.imshow('affin', dst)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

이처럼 어핀 변환 전후 3개의 좌표만 지정해주면 변환 행렬을 알아서 구해줍니다. 상당히 편하죠?

원근 변환(Perspective Transform)

어핀 변환은 이미지를 2차원으로 뒤트는 변환이었습니다. 반면 원근 변환은 이미지를 3차원으로 변환한다고 보시면 됩니다. 멀리 있는 것은 작게 보이고, 가까이 있는 것은 크게 보이는 게 원근법의 원리입니다. 이 원근법의 원리를 적용해 변환하는 방식이 원근 변환입니다. 원근 변환에 필요한 변환 행렬을 반환해주는 함수는 아래와 같습니다.

  • mtrx = cv2.getPerspectiveTransform(pts1, pts2)
    pts1: 변환 이전 영상의 좌표 4개, 4 x 2 배열
    pts2: 변환 이후 영상의 좌표 4개, 4 x 2 배열
    mtrx: 변환행렬 반환, 3 x 3 행렬

지금까지는 이 변환행렬을 cv2.warpAffine() 함수에 전달해주었는데, 원근 변환은 별도의 함수 cv2.warpPerspective() 함수를 써야 합니다. 이 함수의 모든 파라미터는 cv2.warpAffine()과 동일합니다.

# 원근 변환 (perspective.py)

import cv2
import numpy as np

file_name = "../img/fish.jpg"
img = cv2.imread(file_name)
rows, cols = img.shape[:2]

#---① 원근 변환 전 후 4개 좌표
pts1 = np.float32([[0,0], [0,rows], [cols, 0], [cols,rows]])
pts2 = np.float32([[100,50], [10,rows-50], [cols-100, 50], [cols-10,rows-50]])

#---② 변환 전 좌표를 원본 이미지에 표시
cv2.circle(img, (0,0), 10, (255,0,0), -1)
cv2.circle(img, (0,rows), 10, (0,255,0), -1)
cv2.circle(img, (cols,0), 10, (0,0,255), -1)
cv2.circle(img, (cols,rows), 10, (0,255,255), -1)

#---③ 원근 변환 행렬 계산
mtrx = cv2.getPerspectiveTransform(pts1, pts2)
#---④ 원근 변환 적용
dst = cv2.warpPerspective(img, mtrx, (cols, rows))

cv2.imshow("origin", img)
cv2.imshow('perspective', dst)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

실행 결과 이미지의 위쪽 부분의 폭이 좁아져서 마치 멀리 있는 것처럼 보입니다. 이것이 바로 원근 변환입니다. 하지만 실제로 자주 쓰이는 원근 변환은 이와 반대입니다. 위 예제에서는 평면 이미지를 원근 이미지로 변환했지만, 실제 많이 쓰이는 것은 원근 이미지를 평면 이미지로 변환하는 것입니다. 예를 들어 휴대폰 카메라의 스캔 기능을 이용하면 찍힌 문서를 스캔한 문서처럼 만들어 줍니다. 원근감이 있는 문서 이미지를 평면 이미지로 바꿔주는 것입니다. 

# 마우스와 원근 변환으로 문서 스캔 효과 내기 (perspective_scan.py)

import cv2
import numpy as np

win_name = "scanning"
img = cv2.imread("../img/paper.jpg")
rows, cols = img.shape[:2]
draw = img.copy()
pts_cnt = 0
pts = np.zeros((4,2), dtype=np.float32)

def onMouse(event, x, y, flags, param):  #마우스 이벤트 콜백 함수 구현 ---① 
    global  pts_cnt                     # 마우스로 찍은 좌표의 갯수 저장
    if event == cv2.EVENT_LBUTTONDOWN:  
        cv2.circle(draw, (x,y), 10, (0,255,0), -1) # 좌표에 초록색 동그라미 표시
        cv2.imshow(win_name, draw)

        pts[pts_cnt] = [x,y]            # 마우스 좌표 저장
        pts_cnt+=1
        if pts_cnt == 4:                       # 좌표가 4개 수집됨 
            # 좌표 4개 중 상하좌우 찾기 ---② 
            sm = pts.sum(axis=1)                 # 4쌍의 좌표 각각 x+y 계산
            diff = np.diff(pts, axis = 1)       # 4쌍의 좌표 각각 x-y 계산

            topLeft = pts[np.argmin(sm)]         # x+y가 가장 값이 좌상단 좌표
            bottomRight = pts[np.argmax(sm)]     # x+y가 가장 큰 값이 우하단 좌표
            topRight = pts[np.argmin(diff)]     # x-y가 가장 작은 것이 우상단 좌표
            bottomLeft = pts[np.argmax(diff)]   # x-y가 가장 큰 값이 좌하단 좌표

            # 변환 전 4개 좌표 
            pts1 = np.float32([topLeft, topRight, bottomRight , bottomLeft])

            # 변환 후 영상에 사용할 서류의 폭과 높이 계산 ---③ 
            w1 = abs(bottomRight[0] - bottomLeft[0])    # 상단 좌우 좌표간의 거리
            w2 = abs(topRight[0] - topLeft[0])          # 하당 좌우 좌표간의 거리
            h2 = abs(topRight[1] - bottomRight[1])      # 우측 상하 좌표간의 거리
            h2 = abs(topLeft[1] - bottomLeft[1])        # 좌측 상하 좌표간의 거리
            width = max([w1, w2])                       # 두 좌우 거리간의 최대값이 서류의 폭
            height = max([h2, h2])                      # 두 상하 거리간의 최대값이 서류의 높이
            
            # 변환 후 4개 좌표
            pts2 = np.float32([[0,0], [width-1,0], 
                                [width-1,height-1], [0,height-1]])

            # 변환 행렬 계산 
            mtrx = cv2.getPerspectiveTransform(pts1, pts2)
            # 원근 변환 적용
            result = cv2.warpPerspective(img, mtrx, (width, height))
            cv2.imshow('scanned', result)
cv2.imshow(win_name, img)
cv2.setMouseCallback(win_name, onMouse)    # 마우스 콜백 함수를 GUI 윈도우에 등록 ---④
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

문서의 네 꼭짓점을 시계 방향으로 클릭해주면 원근 변환을 활용하여 스캔한 것과 같이 평면 이미지를 만들어 줍니다. 

삼각형 어핀 변환

OpenCV가 제공하는 기하학적 변환은 기본적으로 사각형이 기준입니다. 따라서 삼각형 모양의 변환을 하려면 아래와 같이 복잡한 과정을 거쳐야 합니다.

1. 어핀 변환 전 삼각형 좌표 3개를 정한다.
2. 어핀 변환 후 삼각형 좌표 3개를 정한다.
3. 변환 전 삼각형 좌표를 감싸는 외접 사각형 좌표를 구한다.
4. 변환 후 삼각형 좌표를 감싸는 외접 사각형 좌표를 구한다.
5. 과정 3, 4의 사각형 영역을 관심 영역(ROI, regison of interest)으로 지정한다.
6. 과정 5의 관심 영역을 기준으로 변환 전, 후의 삼각형 좌표를 다시 계산한다.
7. 과정 6의 변환 전 삼각형 좌표를 변환 후 삼각형 좌표로 어핀 변환해주는 변환 행렬을 구한다.
8. 과정 7에서 구한 변환행렬을 적용해 어핀 변환을 한다.
9. 과정 8에서 변환된 관심 영역에서 과정 2의 삼각형 좌표만 마스킹한다.
10. 과정 9에서 구한 마스크를 이용해서 어핀 변환한 이미지와 원본 이미지를 합성한다.

글로만 읽었을 때는 상당히 복잡해 보입니다. 위 과정과 아래의 코드를 함께 보면 이해가 좀 더 수월할 겁니다. 또한, 위의 과정 3, 4처럼 삼각형 좌표를 감싸는 외접 사각형 좌표를 구하려면 cv2.boundingRect() 함수를 써야 합니다.

  • x, y, w, h = cv2.boudingRect(pts)
    pts: 다각형 좌표
    x, y, w, h = 외접 사각형의 좌표와 폭과 높이

그리고 과정 9의 마스크를 구하기 위해 아래 함수가 필요합니다.

  • cv2.fillConvexPoly(img, pts, color, lineTypes)
    img: 입력 이미지
    pts: 다각형 좌표
    color: 다각형을 채울 색상
    lineType(optional): 선 그리기 알고리즘 선택 플래그

아래는 로봇 태권 V 장난감 이미지의 얼굴 부분을 삼각형 어핀 변환하는 예제입니다.

# 삼각형 어핀 변환 (triangle_affine.py)

import cv2
import numpy as np

img = cv2.imread("../img/taekwonv1.jpg")
img2 = img.copy()
draw = img.copy()

# 변환 전,후 삼각형 좌표 ---①
pts1 = np.float32([[188,14], [85,202], [294,216]])
pts2 = np.float32([[128,40], [85,307], [306,167]])

# 각 삼각형을 완전히 감싸는 사각형 좌표 구하기 ---②
x1,y1,w1,h2 = cv2.boundingRect(pts1)
x2,y2,w2,h2 = cv2.boundingRect(pts2)

# 사각형을 이용한 관심영역 설정 ---③
roi1 = img[y1:y1+h2, x1:x1+w1]
roi2 = img2[y2:y2+h2, x2:x2+w2]

# 관심영역을 기준으로 좌표 계산 ---④
offset1 = np.zeros((3,2), dtype=np.float32)
offset2 = np.zeros((3,2), dtype=np.float32)
for i in range(3):
    offset1[i][0], offset1[i][1] = pts1[i][0]-x1, pts1[i][1]-y1
    offset2[i][0], offset2[i][1] = pts2[i][0]-x2, pts2[i][1]-y2

# 관심 영역을 주어진 삼각형 좌표로 어핀 변환 ---⑤
mtrx = cv2.getAffineTransform(offset1, offset2)
warped = cv2.warpAffine( roi1, mtrx, (w2, h2), None, \
                        cv2.INTER_LINEAR, cv2.BORDER_REFLECT_101)

# 어핀 변환 후 삼각형만 골라 내기 위한 마스크 생성 ---⑥
mask = np.zeros((h2, w2), dtype = np.uint8)
cv2.fillConvexPoly(mask, np.int32(offset2), (255))

# 삼각형 영역만 마스킹해서 합성 ---⑦
warped_masked = cv2.bitwise_and(warped, warped, mask=mask)
roi2_masked = cv2.bitwise_and(roi2, roi2, mask=cv2.bitwise_not(mask))
roi2_masked = roi2_masked + warped_masked
img2[y2:y2+h2, x2:x2+w2] = roi2_masked

# 관심 영역과 삼각형에 선 그려서 출력 ---⑧
cv2.rectangle(draw, (x1, y1), (x1+w1, y1+h2), (0,255,0), 1)
cv2.polylines(draw, [pts1.astype(np.int32)], True, (255,0,0), 1)
cv2.rectangle(img2, (x2, y2), (x2+w2, y2+h2), (0,255,0), 1)
cv2.imshow('origin', draw)
cv2.imshow('warped triangle', img2)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

이상으로 이미지를 뒤트는 방법인 어핀 변환, 원근 변환, 삼각형 어핀 변환에 대해 알아봤습니다.

번 포스팅에서는 렌즈를 왜곡하는 방법에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/05.geometric_transform

렌즈 왜곡(Lens Distortion)

지금까지 이미지 이동(Translation), 확대/축소(Scaling), 회전(Rotation), 어핀 변환(Affine Transformation), 원근 변환(Perspective Transformation)에 대해 배웠습니다. 이는 모두 변환 행렬을 이용해서 구할 수 있습니다. 하지만 변환 행렬로는 구할 수 없는 모양의 변환도 있습니다. 렌즈 왜곡 변환이 바로 변환 행렬로는 구할 수 없는 변환입니다. 렌즈 왜곡 변환에는 리매핑, 오목 렌즈/볼록 렌즈 왜곡, 방사 왜곡이 있습니다. 차례대로 살펴보겠습니다.

리매핑(Remapping)

리매핑이란 규칙성 없이 마음대로 이미지의 모양을 변환하는 것을 말합니다. 리매핑을 위해 OpenCV는 cv2.remap()이라는 함수를 제공합니다.

  • dst = cv2.remap(src, mapx, mapy, interpolation, dst, borderMode, borderValue)src: 입력 이미지mapx, mapy: x축과 y축으로 이동할 좌표, src와 동일한 크기, dtype=float32dst(optional): 결과 이미지나머지 인자는 cv2.warpAffine()과 동일

예를 들어, mapx[0][0]=10, mapy[0][0]=5로 지정했다면 이 의미는 src 좌표 (0, 0)에 있는 픽셀을 (10, 5)로 옮기라는 것입니다. mapx와 mapy는 초기 값으로 0 같은 의미 없는 값이 아니라 원래 이미지의 좌표 값을 갖는 것이 좋습니다. 왜냐하면 전체 픽셀 중 옮기고 싶은 픽셀에 대해서만 새로운 좌표를 지정하거나 원래 위치에서 얼마만큼 이동하라고 명령하는 것이 편하기 때문입니다. mapx와 mapy를 np.zeros()로 초기화한 뒤 for문으로 초기화할 수 있지만 이렇게 하면 시간이 너무 오래 걸립니다. 대신 다음과 같이 np.indices() 함수를 쓰면 빠르게 초기화할 수 있습니다.

mapy, mapx = np.indices( (rows, cols), dtype=np.float32)

아래는 np.indices() 함수의 예시입니다. 반환된 결과의 0번째가 행 배열, 1번째가 열 배열입니다. 

>>> np.indices((2,2))
array([[[0, 0],
        [1, 1]],

       [[0, 1],
        [0, 1]]])
        
        
>>> np.indices((3, 3))
array([[[0, 0, 0],
        [1, 1, 1],
        [2, 2, 2]],

       [[0, 1, 2],
        [0, 1, 2],
        [0, 1, 2]]])

아래는 이미지를 뒤집는 코드입니다. 변환 행렬과 cv2.remap() 함수로 각각 구현해서 똑같은 결과를 보여줄 겁니다. 참고로 영상을 뒤집기 위한 연산식은 다음과 같습니다. 이 연산식을 활용하여 변환 행렬을 구할 것입니다.

x' = cols - x - 1
y' = rows - y - 1

# 변환행렬과 리매핑으로 이미지 뒤집기 (remap_flip.py)

import cv2
import numpy as np
import time

img = cv2.imread('../img/yeosu_small.jpg')
rows, cols = img.shape[:2]

# 뒤집기 변환 행렬로 구현 ---①
st = time.time()
mflip = np.float32([ [-1, 0, cols-1],[0, -1, rows-1]]) # 변환 행렬 생성
fliped1 = cv2.warpAffine(img, mflip, (cols, rows))     # 변환 적용
print('matrix:', time.time()-st)

# remap 함수로 뒤집기 구현 ---②
st2 = time.time()
mapy, mapx = np.indices((rows, cols),dtype=np.float32) # 매핑 배열 초기화 생성
mapx = cols - mapx -1                                  # x축 좌표 뒤집기 연산
mapy = rows - mapy -1                                  # y축 좌표 뒤집기 연산
fliped2 = cv2.remap(img,mapx,mapy,cv2.INTER_LINEAR)  # remap 적용
print('remap:', time.time()-st2)

# 결과 출력 ---③
cv2.imshow('origin', img)
cv2.imshow('fliped1',fliped1)
cv2.imshow('fliped2',fliped2)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

변환행렬을 이용하여 뒤집은 이미지와 cv2.remap() 함수로 리매핑하여 뒤집은 이미지의 결과는 똑같습니다. (공간 관계상 뒤집힌 배경 이미지는 하나만 삽입했습니다.) 표현만 다를 뿐이지 그 의미는 같습니다. 그러나 cv2.remap() 함수로 변환하는 것은 변환 행렬로 변환하는 것보다 수행 속도가 더 느립니다. 따라서 변환행렬로 표현할 수 있는 것은 변환행렬로 변환을 하는 것이 좋습니다. 변환행렬로 표현할 수 없는 비선형 변환에만 cv2.remap() 함수를 사용하는 것이 좋습니다.

삼각함수를 이용한 비선형 리매핑

변환 행렬로 표현할 수 없는 비선형 변환을 cv2.remap() 함수로 구현해보겠습니다.

# 삼각함수를 이용한 비선형 리매핑 (remap_sin_cos.py)

import cv2
import numpy as np

l = 20      # 파장(wave length)
amp = 15    # 진폭(amplitude)

img = cv2.imread('../img/taekwonv1.jpg')
rows, cols = img.shape[:2]

# 초기 매핑 배열 생성 ---①
mapy, mapx = np.indices((rows, cols),dtype=np.float32)

# sin, cos 함수를 적용한 변형 매핑 연산 ---②
sinx = mapx + amp * np.sin(mapy/l)  
cosy = mapy + amp * np.cos(mapx/l)

# 영상 매핑 ---③

img_sinx=cv2.remap(img, sinx, mapy, cv2.INTER_LINEAR) # x축만 sin 곡선 적용
img_cosy=cv2.remap(img, mapx, cosy, cv2.INTER_LINEAR) # y축만 cos 곡선 적용
# x,y 축 모두 sin, cos 곡선 적용 및 외곽 영역 보정
img_both=cv2.remap(img, sinx, cosy, cv2.INTER_LINEAR, \
                    None, cv2.BORDER_REPLICATE)
# 결과 출력 
cv2.imshow('origin', img)
cv2.imshow('sin x', img_sinx)
cv2.imshow('cos y', img_cosy)
cv2.imshow('sin cos', img_both)

cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

x좌표, y좌표에 sin, cos 함수를 적용하여 새로운 사인파 코사인파 곡선으로 왜곡된 이미지를 만들어 주었습니다. 마지막 이미지에는 sin, cos 함수를 모두 적용하였는데, cv2.BORDER_REPLICATE 파라미터로 외곽 보정까지 해주어 외곽의 사라진 영역이 보정된 것을 볼 수 있습니다.

오목 렌즈와 볼록 렌즈 왜곡

오목 렌즈, 볼록 렌즈 왜곡에 대해서 살펴보기 전에 직교 좌표계, 극좌표계에 대해 먼저 알아보겠습니다. 우선, 아래 그림을 보겠습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://suhak.tistory.com/161

우리는 일반적으로 x축과 y축의 직각으로 각각 선을 그어서 만나는 지점을 좌표 (x, y)로 나타냅니다. 이러한 형태의 좌표 시스템을 직교 좌표계(Cartesian coordinate system)라고 부릅니다. 반면, 원점으로부터의 거리(r)와 사잇각(Θ)을 이용해서 (r, Θ)로 나타내는 방법이 있는데, 이를 극좌표계(Polar coordinate system)라고 부릅니다. 두 좌표계는 상호 변환이 가능합니다. 좌표 변환은 연산식을 이용해도 되지만, OpenCV는 좌표 변환을 위해 다음과 같은 함수를 제공합니다.

r, theta = cv2.cartToPolar(x, y): 직교 좌표 → 극좌표 변환

x, y = cv2.polarToCart(r, theta): 극좌표 → 직교 좌표 변환

좌표의 변환뿐만 아니라 좌표의 기준점 변환도 중요합니다. 일반적으로 직교 좌표계를 사용할 때는 좌측 상단을 원점(0, 0)으로 정합니다. 그러나 극좌표에서는 이미지의 중앙을 원점으로 해야 합니다. 이미지의 중앙을 (0, 0)으로 두기 위해서 좌표의 값을 -1 ~ 1로 정규화해야 합니다.

# 볼록/오몬 렌즈 왜곡 효과 (remap_lens.py)

import cv2
import numpy as np

img = cv2.imread('../img/taekwonv1.jpg')
print(img.shape)
rows, cols = img.shape[:2]

# ---① 설정 값 셋팅
exp = 2       # 볼록, 오목 지수 (오목 : 0.1 ~ 1, 볼록 : 1.1~)
scale = 1           # 변환 영역 크기 (0 ~ 1)

# 매핑 배열 생성 ---②
mapy, mapx = np.indices((rows, cols),dtype=np.float32)

# 좌상단 기준좌표에서 -1~1로 정규화된 중심점 기준 좌표로 변경 ---③
mapx = 2*mapx/(cols-1)-1
mapy = 2*mapy/(rows-1)-1

# 직교좌표를 극 좌표로 변환 ---④
r, theta = cv2.cartToPolar(mapx, mapy)

# 왜곡 영역만 중심확대/축소 지수 적용 ---⑤
r[r< scale] = r[r<scale] **exp  

# 극 좌표를 직교좌표로 변환 ---⑥
mapx, mapy = cv2.polarToCart(r, theta)

# 중심점 기준에서 좌상단 기준으로 변경 ---⑦
mapx = ((mapx + 1)*cols-1)/2
mapy = ((mapy + 1)*rows-1)/2
# 재매핑 변환
distorted = cv2.remap(img,mapx,mapy,cv2.INTER_LINEAR)

cv2.imshow('origin', img)
cv2.imshow('distorted', distorted)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

exp는 이미지의 왜곡 지수를 나타내는 변수로 1이면 원본과 동일하게 하고, 1보다 작으면 오목 렌즈 효과를 내고, 1보다 크면 볼록 렌즈 효과를 냅니다. 맨 오른쪽은 exp=0.5로 설정한 결과입니다. scale은 이미지에서 렌즈 효과를 주고 싶은 원 모양 영역의 크기를 비율로 나타낸 것입니다. scale=1은 100%를 의미합니다.

그리고 아래 코드는 좌표의 기준점을 바꾸고 -1~1 범위로 정규화하는 기능을 합니다. 이는 이후에 다시 좌상단 기준점으로 변경됩니다.

mapx = 2*mapx/(cols-1)-1
mapy = 2*mapy/(rows-1)-1

실질적인 렌즈 효과는 아래 코드에서 이루어집니다.

r[r< scale] = r[r<scale] **exp  

앞서 좌표의 범위를 -1~1로 정규화했습니다. 따라서 scale의 최댓값은 1입니다. 1을 넘는 값을 가질 수는 없습니다. 극좌표계로 바꿨을 때의 r은 원의 반지름입니다. 반지름이 scale보다 작은 범위에 있는 좌표에 대해서는 exp(왜곡 지수)를 곱해주었습니다. 이때 왜곡 지수가 1보다 크면 볼록 렌즈 효과를, 1보다 작으면 오목 렌즈 효과를 줍니다.

방사 왜곡

카메라를 통해 이미지를 촬영할 때 카메라 가장자리 부분에서 약간의 왜곡이 생기는 것을 본 적이 있을 겁니다. 이런 현상을 배럴 왜곡(barrel distortiaon)이라고 합니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: http://truemind1.blogspot.com/2016/10/06-2-vr-distortion.html

위 그림과 같이 실제 이미지를 렌즈를 통해 촬영하면 가장자리 부분이 약간 둥그스름해지는 것입니다. 둥근 모양이 배럴 통 같다고 하여 배럴 왜곡이라고 합니다. 반면, 가장자리 부분이 안쪽으로 들어가는 형태의 왜곡을 핀쿠션 왜곡(pinsushion distortion)이라고 합니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: http://egloos.zum.com/eggry/v/4122911

배럴 왜곡과 핀쿠션 왜곡을 구현해보겠습니다.

# 배럴 왜곡, 핀쿠션 왜곡 (reamp_barrel.py)

import cv2
import numpy as np

# 왜곡 계수 설정 ---①
k1, k2, k3 = 0.5, 0.2, 0.0 # 배럴 왜곡
#k1, k2, k3 = -0.3, 0, 0    # 핀큐션 왜곡

img = cv2.imread('../img/girl.jpg')
rows, cols = img.shape[:2]

# 매핑 배열 생성 ---②
mapy, mapx = np.indices((rows, cols),dtype=np.float32)

# 중앙점 좌표로 -1~1 정규화 및 극좌표 변환 ---③
mapx = 2*mapx/(cols-1)-1
mapy = 2*mapy/(rows-1)-1
r, theta = cv2.cartToPolar(mapx, mapy)

# 방사 왜곡 변영 연산 ---④
ru = r*(1+k1*(r**2) + k2*(r**4) + k3*(r**6)) 

# 직교좌표 및 좌상단 기준으로 복원 ---⑤
mapx, mapy = cv2.polarToCart(ru, theta)
mapx = ((mapx + 1)*cols-1)/2
mapy = ((mapy + 1)*rows-1)/2
# 리매핑 ---⑥
distored = cv2.remap(img,mapx,mapy,cv2.INTER_LINEAR)

cv2.imshow('original', img)
cv2.imshow('distorted', distored)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

코드의 아래 부분만 제외하고는 오목 렌즈, 볼록 렌즈 왜곡과 전체적인 프로세스는 비슷합니다.

ru = r*(1+k1*(r**2) + k2*(r**4) + k3*(r**6)) 

위 연산식은 배럴 왜곡, 핀쿠션 왜곡을 적용해주는 연산식입니다. k1, k2, k3 값에 따라 배럴 왜곡이 될 수도 있고 핀쿠션 왜곡이 될 수도 있습니다. 왜곡 계수 설정하는 코드에서 주석을 서로 바꿔가며 실행해보시기 바랍니다.

이번 포스팅에서는 지금까지 배웠던 내용을 기반으로 실습을 해보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/05.geometric_transform

실습 1: 모자이크 처리

이미지의 특정 영역을 마우스로 클릭하면 모자이크 처리가 되게 하는 실습을 해보겠습니다. 모자이크 처리의 원리는 간단합니다. 모자이크를 적용할 관심 영역의 이미지를 특정 비율로 축소시킨 뒤 다시 확대를 하면 됩니다. 크기가 작은 이미지를 최대 픽셀 값 이상의 크기로 확대하면 이미지가 깨집니다. 이 원리를 적용한 것입니다. 관심 영역을 축소했다가 다시 확대하면 원래의 픽셀과 비슷하긴 하지만, 보간법에 의해서 연산한 결과라서 선명도가 떨어져 뿌옇게 보입니다. 이때 보간법은 cv2.INTER_AREA를 사용해야 합니다. 

# 모자이크 처리 (workshop_mosaic.py)

import cv2

rate = 15               # 모자이크에 사용할 축소 비율 (1/rate)
win_title = 'mosaic'    # 창 제목
img = cv2.imread('../img/taekwonv1.jpg')    # 이미지 읽기

while True:
    x,y,w,h = cv2.selectROI(win_title, img, False) # 관심영역 선택
    if w and h:
        roi = img[y:y+h, x:x+w]   # 관심영역 지정
        roi = cv2.resize(roi, (w//rate, h//rate)) # 1/rate 비율로 축소
        # 원래 크기로 확대
        roi = cv2.resize(roi, (w,h), interpolation=cv2.INTER_AREA)  
        img[y:y+h, x:x+w] = roi   # 원본 이미지에 적용
        cv2.imshow(win_title, img)
    else:
        break
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

아래는 다른 rate를 적용한 모자이크 결과입니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

왼쪽은 rate를 35, 오른쪽은 rate를 5로 설정했을 때 모자이크 이미지입니다. rate 값이 크면 더 큰 비율로 축소했다가 다시 확대하니 픽셀이 많이 깨집니다. 반면 rate 값이 작으면 살짝만 축소했다가 다시 확대하니 픽셀이 많이 안 깨지는 것을 볼 수 있습니다.

실습 2: 리퀴파이 도구

많은 사람들이 셀카를 찍은 뒤 어플을 이용해 보정하곤 합니다. 눈을 키우고 턱을 깎죠. 이런 기능을 리퀴파이(Liquify)라고 합니다. 즉, 이미지의 원하는 부분만 작게 하거나 크게 하는 기능을 리퀴파이라고 합니다. Liquify는 '액체로 만들다'라는 뜻으로 이미지의 일부분을 액체처럼 흐물거리게 바꾸는 효과를 말합니다.

사각형 영역을 4개의 삼각형 영역으로 나눕니다. 마우스의 위치를 가운데 교차점으로 둡니다. 마우스를 드래그하면 교차점의 위치가 오른쪽과 같이 변합니다. 그러면 각 4개의 삼각형의 크기가 바뀝니다. 4개의 삼각형에 대해서 각각 어핀 변환합니다. 어핀 변환에 대해서는 14장을 참고해주시기 바랍니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

이 원리를 이용하여 리퀴파이 도구를 만들어보겠습니다.

# 포토샵 리퀴파이 도구 (workshop_liquify_tool.py)

import cv2
import numpy as np

win_title = 'Liquify'   # 창 이름
half = 50               # 관심 영역 절반 크기
isDragging = False      # 드래그 여부 플래그

# 리퀴파이 함수
def liquify(img, cx1,cy1, cx2,cy2) :
    # 대상 영역 좌표와 크기 설정
    x, y, w, h = cx1-half, cy1-half, half*2, half*2
    # 관심 영역 설정
    roi = img[y:y+h, x:x+w].copy()
    out = roi.copy()

    # 관심영역 기준으로 좌표 재 설정
    offset_cx1,offset_cy1 = cx1-x, cy1-y
    offset_cx2,offset_cy2 = cx2-x, cy2-y
    
    # 변환 이전 4개의 삼각형 좌표
    tri1 = [[ (0,0), (w, 0), (offset_cx1, offset_cy1)], # 상,top
            [ [0,0], [0, h], [offset_cx1, offset_cy1]], # 좌,left
            [ [w, 0], [offset_cx1, offset_cy1], [w, h]], # 우, right
            [ [0, h], [offset_cx1, offset_cy1], [w, h]]] # 하, bottom

    # 변환 이후 4개의 삼각형 좌표
    tri2 = [[ [0,0], [w,0], [offset_cx2, offset_cy2]], # 상, top
            [ [0,0], [0, h], [offset_cx2, offset_cy2]], # 좌, left
            [ [w,0], [offset_cx2, offset_cy2], [w, h]], # 우, right
            [ [0,h], [offset_cx2, offset_cy2], [w, h]]] # 하, bottom

    
    for i in range(4):
        # 각각의 삼각형 좌표에 대해 어핀 변환 적용
        matrix = cv2.getAffineTransform( np.float32(tri1[i]), \
                                         np.float32(tri2[i]))
        warped = cv2.warpAffine( roi.copy(), matrix, (w, h), \
            None, flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT_101)
        # 삼각형 모양의 마스크 생성
        mask = np.zeros((h, w), dtype = np.uint8)
        cv2.fillConvexPoly(mask, np.int32(tri2[i]), (255,255,255))
        
        # 마스킹 후 합성
        warped = cv2.bitwise_and(warped, warped, mask=mask)
        out = cv2.bitwise_and(out, out, mask=cv2.bitwise_not(mask))
        out = out + warped

    # 관심 영역을 원본 영상에 합성
    img[y:y+h, x:x+w] = out
    return img 

# 마우스 이벤트 핸들 함수
def onMouse(event,x,y,flags,param):     
    global cx1, cy1, isDragging, img      # 전역변수 참조
    # 마우스 중심 점을 기준으로 대상 영역 따라다니기
    if event == cv2.EVENT_MOUSEMOVE:  
        if not isDragging :
            img_draw = img.copy()       
            # 드래그 영역 표시
            cv2.rectangle(img_draw, (x-half, y-half), \
                    (x+half, y+half), (0,255,0)) 
            cv2.imshow(win_title, img_draw) # 사각형 표시된 그림 화면 출력
    elif event == cv2.EVENT_LBUTTONDOWN :   
        isDragging = True                   # 드래그 시작
        cx1, cy1 = x, y                     # 드래그 시작된 원래의 위치 좌표 저장
    elif event == cv2.EVENT_LBUTTONUP :
        if isDragging:
            isDragging = False              # 드래그 끝
            # 드래그 시작 좌표와 끝난 좌표로 리퀴파이 적용 함수 호출
            liquify(img, cx1, cy1, x, y)    
            cv2.imshow(win_title, img)

if __name__ == '__main__' :
    img = cv2.imread("../img/taekwonv1.jpg")
    h, w = img.shape[:2]

    cv2.namedWindow(win_title)
    cv2.setMouseCallback(win_title, onMouse) 
    cv2.imshow(win_title, img)
    while True:
        key = cv2.waitKey(1)
        if key & 0xFF == 27:
            break
    cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

마우스로 드래그를 하면 이미지에 리퀴파이 효과를 낼 수 있습니다. 위 코드는 주석을 다라 차례차례 읽으면 이해가 잘 될 겁니다. 혹시 이해가 잘 안 가는 부분이 있다면 댓글로 남겨주세요. 자세히 설명해드리겠습니다.

실습 3: 왜곡 거울 카메라

지금까지 배웠던 왜곡 기법을 이용해서 다양한 왜곡 거울 카메라를 만들어보겠습니다. 아래 코드는 대부분 지금까지 다루었던 내용이므로 주석을 따라 읽으면 이해가 쉬울 겁니다.

# 왜곡 거울 카메라 (workshop_distortion_camera.py)

import cv2
import numpy as np

cap = cv2.VideoCapture(0)
WIDTH = 500
HEIGHT = 300
cap.set(cv2.CAP_PROP_FRAME_WIDTH, WIDTH)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, HEIGHT)
rows, cols = HEIGHT, WIDTH
map_y, map_x = np.indices((rows, cols), dtype=np.float32)

# 거울 왜곡 효과 
map_mirrorh_x,map_mirrorh_y = map_x.copy(), map_y.copy() 
map_mirrorv_x,map_mirrorv_y = map_x.copy(), map_y.copy()    
## 좌우 대칭 거울 좌표 연산
map_mirrorh_x[: , cols//2:] = cols - map_mirrorh_x[:, cols//2:]-1
## 상하 대칭 거울 좌표 연산
map_mirrorv_y[rows//2:, :] = rows - map_mirrorv_y[rows//2:, :]-1
# 물결 효과
map_wave_x, map_wave_y = map_x.copy(), map_y.copy()
map_wave_x = map_wave_x + 15*np.sin(map_y/20)
map_wave_y = map_wave_y + 15*np.sin(map_x/20)    


# 렌즈 효과
## 렌즈 효과, 중심점 이동
map_lenz_x = 2*map_x/(cols-1)-1
map_lenz_y = 2*map_y/(rows-1)-1
## 렌즈 효과, 극좌표 변환
r, theta = cv2.cartToPolar(map_lenz_x, map_lenz_y)
r_convex = r.copy()
r_concave = r.copy()
## 볼록 렌즈 효과 매핑 좌표 연산
r_convex[r< 1] = r_convex[r<1] **2  
print(r.shape, r_convex[r<1].shape)
## 오목 렌즈 효과 매핑 좌표 연산
r_concave[r< 1] = r_concave[r<1] **0.5
## 렌즈 효과, 직교 좌표 복원
map_convex_x, map_convex_y = cv2.polarToCart(r_convex, theta)
map_concave_x, map_concave_y = cv2.polarToCart(r_concave, theta)
## 렌즈 효과, 좌상단 좌표 복원
map_convex_x = ((map_convex_x + 1)*cols-1)/2
map_convex_y = ((map_convex_y + 1)*rows-1)/2
map_concave_x = ((map_concave_x + 1)*cols-1)/2
map_concave_y = ((map_concave_y + 1)*rows-1)/2

while True:
    ret, frame = cap.read()
    frame = frame[:HEIGHT, :WIDTH]
    # 준비한 매핑 좌표로 영상 효과 적용
    mirrorh=cv2.remap(frame,map_mirrorh_x,map_mirrorh_y,cv2.INTER_LINEAR)
    mirrorv=cv2.remap(frame,map_mirrorv_x,map_mirrorv_y,cv2.INTER_LINEAR)
    wave = cv2.remap(frame,map_wave_x,map_wave_y,cv2.INTER_LINEAR, \
                    None, cv2.BORDER_REPLICATE)
    convex = cv2.remap(frame,map_convex_x,map_convex_y,cv2.INTER_LINEAR)
    concave = cv2.remap(frame,map_concave_x,map_concave_y,cv2.INTER_LINEAR)
    # 영상 합치기
    r1 = np.hstack(( frame, mirrorh, mirrorv))
    r2 = np.hstack(( wave, convex, concave))
    merged = np.vstack((r1, r2))

    cv2.imshow('distorted', merged)
    if cv2.waitKey(1) & 0xFF== 27:
        break
cap.release
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

일반 카메라, 좌우 대칭 카메라, 상하 대칭 카메라, 물경 왜곡 카메라, 볼록 렌즈 카메라, 오목 렌즈 카메라를 구현하여 하나로 이어 붙였습니다.

이번 포스팅부터는 영상 필터에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/06.filter

시작하기 앞서 용어에 대해 먼저 정의하고 가겠습니다. 영상 처리는 새로운 영상을 얻기 위해 기존 픽셀 값에 어떤 연산을 가해서 새로운 픽셀 값을 얻는 작업입니다. 새로운 픽셀 값을 얻을 때 하나의 픽셀 값이 아닌 그 주변 픽셀들의 값을 활용하는 방법을 공간 영역 필터링(spacial domain filtering)라고 합니다. 또한, 블러링(Blurring)이란 기존의 영상을 흐릿하게 만드는 작업을 뜻합니다.

필터(Filter)와 컨볼루션(Convolution)

컨볼루션 연산은 공간 영역 필터링을 위한 핵심 연산 방법입니다. 블러링 작업을 예로 들어 컨볼루션 연산이 어떻게 진행되는지 살펴보겠습니다.

공간 영역 필터링은 연산 대상 픽셀과 그 주변 픽셀들을 활용하여 새로운 픽셀 값을 얻는 방법이라고 했습니다. 이때 주변 픽셀을 어느 범위까지 활용할지 그리고 연산은 어떻게 할지를 결정해야 합니다. 이런 역할을 하는 것이 바로 커널(kernel)입니다. 커널은 윈도(window), 필터(filter), 마스크(mask)라고도 부릅니다. 아래 그림에서 가운데 있는 3 x 3 짜리 행렬이 바로 커널입니다. 

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://www.slipp.net/wiki/pages/viewpage.action?pageId=26641520

위 그림은 3 x 3 커널로 컨볼루션 연산을 하는 예시입니다. 기존 영상에서 픽셀 값 6을 기준으로 주변에 있는 픽셀 값인 3, 0, 1, 2, 1, 4, 2, 2(시계 방향)까지 활용했습니다. 일대일로 대응하는 위치에 있는 커널의 요소와 대응하는 입력 픽셀 값을 곱해서 모두 합한 것을 결과 픽셀 값으로 결정했습니다. 이런 연산을 마지막 픽셀까지 반복하는 것을 컨볼루션 연산이라고 합니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://www.slipp.net/wiki/pages/viewpage.action?pageId=26641520

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://www.slipp.net/wiki/pages/viewpage.action?pageId=26641520

OpenCV에서는 아래 함수로 컨볼루션 연산을 지원합니다.

  • dst = cv2.filter2D(src, ddepth, kernel, dst, anchor, delta, borderType)
    src: 입력 영상, Numpy 배열
    ddepth: 출력 영상의 dtype (-1: 입력 영상과 동일)
    kernel: 컨볼루션 커널, float32의 n x n 크기 배열
    dst(optional): 결과 영상
    anchor(optional): 커널의 기준점, default: 중심점 (-1, -1)
    delta(optional): 필터가 적용된 결과에 추가할 값borderType(optional): 외곽 픽셀 보정 방법 지정

평균 블러링(Average Blurring)

앞서 설명했듯이 블러링은 초점이 맞지 않듯이 영상을 흐릿하게 하는 작업을 뜻합니다. 가장 간단한 블러링 방법으로는 평균 블러링이 있습니다. 평균 블러링은 주변 픽셀 값들의 평균을 적용합니다. 주변 픽셀들의 평균값을 적용하면 픽셀 간 차이가 적어져 선명도가 떨어져 전체적으로 흐릿해집니다.

아래는 5 x 5 평균 블러링 필터를 활용하여 컨볼루션 연산을 적용한 예시입니다.

# 평균 필터를 생상하여 블러 적용 (blur_avg_kernel.py)

import cv2
import numpy as np

img = cv2.imread('../img/yeosu_small.jpg')
'''
#5x5 평균 필터 커널 생성    ---①
kernel = np.array([[0.04, 0.04, 0.04, 0.04, 0.04],
                   [0.04, 0.04, 0.04, 0.04, 0.04],
                   [0.04, 0.04, 0.04, 0.04, 0.04],
                   [0.04, 0.04, 0.04, 0.04, 0.04],
                   [0.04, 0.04, 0.04, 0.04, 0.04]])
'''
# 5x5 평균 필터 커널 생성  ---②
kernel = np.ones((5,5))/5**2
# 필터 적용             ---③
blured = cv2.filter2D(img, -1, kernel)

# 결과 출력
cv2.imshow('origin', img)
cv2.imshow('avrg blur', blured) 
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

원본인 여수 야경에 평균 블러링을 적용하니 흐릿해진 것을 볼 수 있습니다. 위 코드에서 np.ones((5, 5))/5**2는 평균 블러링 필터 역할을 합니다. 위에서 맨 처음 컨볼루션 연산을 배울 때는 원본 이미지의 픽셀 값과 그에 대응하는 필터의 픽셀 값을 요소 별로 곱한 뒤 모두 합해줬습니다. 하지만 여기서는 평균 블러링을 적용해야 하므로 5 x 5 필터의 요소 개수인 5**2(=25)로 나누어 준 것입니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://hsg2510.tistory.com/112

필터의 크기가 클수록 평균 블러링을 적용했을 때 선명도가 더 떨어집니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://hsg2510.tistory.com/112

위와 같이 개발자가 직접 커널을 생성하지 않고도 평균 블러링을 적용할 수 있습니다. OpenCV에서는 아래와 같은 평균 블러링 함수를 제공합니다.

  • dst = cv2.blur(src, ksize, dst, anchor, borderType)
    src: 입력 영상, numpy 배열
    ksize: 커널의 크기
    나머지 파라미터는 cv2.filter2D()와 동일
  • dst = cv2.boxFilter(src, ddepth, ksize, dst, anchor, normalize, borderType)
    ddepth: 출력 영상의 dtype (-1: 입력 영상과 동일)normalize(optional): 커널 크기로 정규화(1/ksize²) 지정 여부 (Boolean), default=True나머지 파라미터는 cv2.filter2D()와 동일

cv2.blur() 함수는 커널의 크기만 정해주면 알아서 평균 커널을 생성해서 평균 블러링을 적용한 영상을 출력합니다. 커널 크기는 일반적으로 홀수로 정합니다. cv2.boxFilter() 함수는 normalize에 True를 전달하면 cv2.blur() 함수와 동일한 기능을 합니다.

아래는 cv2.blur() 함수와 cv2.boxFilter() 함수를 이용하여 평균 블러링을 적용하는 예제 코드입니다.

# 블러 전용 함수로 블러링 적용 (blur_avg_api.py)

import cv2
import numpy as np

file_name = '../img/taekwonv1.jpg'
img = cv2.imread(file_name)

# blur() 함수로 블러링  ---①
blur1 = cv2.blur(img, (10,10))
# boxFilter() 함수로 블러링 적용 ---②
blur2 = cv2.boxFilter(img, -1, (10,10))

# 결과 출력
merged = np.hstack( (img, blur1, blur2))
cv2.imshow('blur', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

 cv2.blur() 함수와 cv2.boxFilter() 함수가 동일한 결과를 냈음을 알 수 있습니다.

가우시안 블러링(Gaussian Blurring)

가우시안 분포를 갖는 커널로 블러링 하는 것을 가우시안 블러링이라고 합니다. 가우시안 분포(gaussian distribution)란 정규 분포(normal distribution)이라고도 하는데, 평균 근처에 몰려 있는 값들의 개수가 많고 평균에서 멀어질수록 그 개수가 적어지는 분포를 말합니다.

가우시안 블러링 커널은 아래와 같이 중앙값이 가장 크고 중앙에서 멀어질수록 그 값이 작아집니다. 

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://hsg2510.tistory.com/112

첫 번째 커널에서 16으로 나눈 이유는 커널의 모든 요소의 합이 16이기 때문입니다. (1+2+1+2+4+2+1+2+1 = 16) 두 번째 커널도 모든 요소의 합이 256이므로 256으로 나누어준 것입니다. 이런 가우시안 블러링 커널을 적용하면 대상 픽셀에 가까울수록 많은 영향을 주고, 멀어질수록 적은 영향을 주기 때문에 원래의 영상과 비슷하면서도 노이즈를 제거하는 효과가 있습니다.

OpenCV에서는 아래와 같이 가우시안 블러링을 적용하는 함수를 제공합니다.

  • cv2.GaussianBlur(src, ksize, sigmaX, sigmaY, borderType)
    src: 입력 영상
    ksize: 커널 크기 (주로 홀수)
    sigmaX: X 방향 표준편차 (0: auto)
    sigmaY(optional): Y 방향 표준편차 (default: sigmaX)
    borderType(optional): 외곽 테두리 보정 방식
  • ret = cv2.getGaussianKernel(ksize, sigma, ktype)
    ret: 가우시안 커널 (1차원이므로 ret * ret.T 형태로 사용해야 함)

cv2.GaussianBlur() 함수는 커널 크기와 표준 편차를 전달하면 가우시안 블러링을 적용해줍니다. sigmaX에 0을 전달하면 자동으로 표준편차를 선택해서 사용하고, sigmaY를 생략하면 sigmaX 값과 동일하게 적용합니다.

cv2.getGaussianKernel() 함수는 커널 크기와 표준 편차를 전달하면 가우시안 필터를 반환합니다. 반환된 필터는 1차원이므로 cv2.filter2D() 함수에 사용하려면 ret * ret.T와 같은 형식으로 전달해야 합니다.

# 가우시안 블러링 (blur_gaussian.py)

import cv2
import numpy as np

img = cv2.imread('../img/gaussian_noise.jpg')

# 가우시안 커널을 직접 생성해서 블러링  ---①
k1 = np.array([[1, 2, 1],
                   [2, 4, 2],
                   [1, 2, 1]]) *(1/16)
blur1 = cv2.filter2D(img, -1, k1)

# 가우시안 커널을 API로 얻어서 블러링 ---②
k2 = cv2.getGaussianKernel(3, 0)
blur2 = cv2.filter2D(img, -1, k2*k2.T)

# 가우시안 블러 API로 블러링 ---③
blur3 = cv2.GaussianBlur(img, (3, 3), 0)

# 결과 출력
print('k1:', k1)
print('k2:', k2*k2.T)
merged = np.hstack((img, blur1, blur2, blur3))
cv2.imshow('gaussian blur', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

첫 번째로 가우시안 필터를 직접 생성해서 cv2.filter2D() 함수에 전달하여 블러링을 적용했습니다. 두 번째로 cv2.getGaussianKernel() 함수를 이용해 가우시안 커널을 얻었습니다. 얻은 커널을 역시 cv2.filter2D() 함수에 전달하여 블러링을 적용했습니다. 이때 주의할 것은 k2*k2.T와 같은 형태로 전달해야 한다는 점입니다. 마지막으로 cv2.GaussianBlur() 함수를 활용하여 필터를 별도로 구하지 않고 직접 가우시안 블러링을 적용했습니다. 결과 이미지가 작아서 잘 안 보이겠지만 노이즈가 제거된 것을 알 수 있습니다. 이렇듯 가우시안 블러링은 노이즈를 제거하는 효과가 있습니다.

미디언 블러링(Median Blurring)

커널의 픽셀 값 중 중앙값을 선택하는 것을 미디언 블러링이라고 합니다. 미디언 블러링은 소금-후추 잡음을 제거하는 효과가 있습니다. 소금-후추 잡음이란 이미지에 소금과 후추를 뿌린 것과 같이 생긴 잡음을 뜻합니다. OpenCV는 미디언 블러링을 위해 아래 함수를 제공합니다.

  • dst = cv2.medianBlur(src, ksize)
    src: 입력 영상
    ksize: 커널 크기
# 미디언 블러링 (blur_median.py)

import cv2
import numpy as np

img = cv2.imread("../img/salt_pepper_noise.jpg")

# 미디언 블러 적용 --- ①
blur = cv2.medianBlur(img, 5)

# 결과 출력 
merged = np.hstack((img,blur))
cv2.imshow('media', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

보시는 바와 같이 미디언 블러링을 적용하니 소금-후추 잡음이 제거되었습니다.

바이레터럴 필터(Bilateral Filter)

지금까지 적용한 블러링은 잡음을 제거하는 효과는 뛰어났지만 그만큼 경계도 흐릿하게 만드는 문제가 있었습니다. 바이레터럴 필터는 이를 개선하기 위해 가우시안 필터와 경계 필터를 결합합니다. 경계도 뚜렷하고 노이즈도 제거되는 효과가 있지만 속도가 느리다는 단점이 있습니다.

  • dst = cv2.bilateralFilter(src, d, sigmaColor, sigmaSpace, dst, borderType)
    src: 입력 영상
    d: 필터의 직경(diameter), 5보다 크면 매우 느림
    sigmaColor: 색공간의 시그마 값
    sigmaSpace: 좌표 공간의 시그마 값

일반적으로 sigmaColor와 sigmaSpace는 같은 값을 사용하며, 값의 범위는 10~150을 권장합니다.

# 바이레터럴 필터와 가우시안 필터 비교 (blur_bilateral.py)

import cv2
import numpy as np

img = cv2.imread("../img/gaussian_noise.jpg")

# 가우시안 필터 적용 ---①
blur1 = cv2.GaussianBlur(img, (5,5), 0)

# 바이레터럴 필터 적용 ---②
blur2 = cv2.bilateralFilter(img, 5, 75, 75)

# 결과 출력
merged = np.hstack((img, blur1, blur2))
cv2.imshow('bilateral', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

가우시안 필터를 적용했을 때는 경곗값이 흐릿하지만, 바이레터럴 필터를 적용했을 때는 노이즈는 줄면서 경곗값을 유지되는 것을 볼 수 있습니다. 

이번 포스팅부터는 경계를 검출하는 방법에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/06.filter

지금까지는 영상을 흐릿하게 하는 블러링에 대해 알아봤습니다. 이번 포스팅에서는 반대로 영상의 경계를 뚜렷하게 만드는 방법에 대해 알아보겠습니다. 영상의 경계를 선명하고 뚜렷하게 만드는 작업을 샤프닝(sharpening)이라고 합니다. 샤프닝은 영상에서 경계를 검출하여 경계에 있는 픽셀을 강조합니다.

기본 미분 필터

경계(엣지)를 검출하기 위해서는 픽셀 값이 급격하게 변하는 지점을 찾아야 합니다. 경계 부분에서는 당연히 픽셀 값이 급격히 변하겠죠? 이는 연속된 픽셀 값에 미분을 하여 찾아낼 수 있습니다. 하지만 픽셀은 연속 공간 안에 있지 않으므로 미분 근사값을 구해야 합니다. 미분 근사값은 간단합니다. 서로 붙어 있는 픽셀 값을 빼면 됩니다. x방향, y방향으로 각각 픽셀 값을 빼면 미분 근사값이 됩니다. 

OpenCV 형상 인식 - OpenCV hyeongsang insig

영상 내 픽셀 값의 미분 근사값 연산을 위한 컨볼루션 커널은 아래와 같습니다. 요소가 -1과 1인 이유는 단지 접해있는 픽셀 값을 빼기 때문입니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

이 미분 컨볼루션 커널을 이용하여 경계를 검출해보겠습니다.

# 미분 커널로 경계 검출 (edge_differential.py)

import cv2
import numpy as np

img = cv2.imread("../img/sudoku.jpg")

#미분 커널 생성 ---①
gx_kernel = np.array([[ -1, 1]])
gy_kernel = np.array([[ -1],[ 1]])

# 필터 적용 ---②
edge_gx = cv2.filter2D(img, -1, gx_kernel)
edge_gy = cv2.filter2D(img, -1, gy_kernel)
# 결과 출력
merged = np.hstack((img, edge_gx, edge_gy))
cv2.imshow('edge', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

x방향, y방향의 미분 커널을 생성하여 필터링을 적용했습니다. x방향 미분 필터는 세로 방향의 경계를 검출했고, y방향 미분 필터는 가로 방향의 경계를 검출했습니다. x방향 미분 필터는 좌우 픽셀 값의 차를 기반으로 필터링했기 때문에 세로 방향의 경계를 검출한 것이고, 반대로 y방향 미분 필터는 상하 픽셀 값의 차를 기반으로 필터링했기 때문에 가로 방향의 경계를 검출한 것입니다.

로버츠 교차 필터 (Roberts Cross Filter)

로렌스 로버츠라는 미국 엔지니어는 기본 미분 필터를 개선한 로버츠 교차 필터를 제안했습니다. 로버츠 교차 필터를 위한 컨볼루션 커널은 아래와 같습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

이 커널은 대각선 방향으로 +1과 -1을 배치시켜 사선 경계 검출 효과를 높였습니다. 하지만 노이즈에 민감하다는 단점이 있습니다.

# 로버츠 교차 필터를 적용한 경계 검출 (edge_roberts.py)

import cv2
import numpy as np

img = cv2.imread("../img/sudoku.jpg")

# 로버츠 커널 생성 ---①
gx_kernel = np.array([[1,0], [0,-1]])
gy_kernel = np.array([[0, 1],[-1,0]])

# 커널 적용 ---② 
edge_gx = cv2.filter2D(img, -1, gx_kernel)
edge_gy = cv2.filter2D(img, -1, gy_kernel)

# 결과 출력
merged = np.hstack((img, edge_gx, edge_gy, edge_gx+edge_gy))
cv2.imshow('roberts cross', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

프리윗 필터 (Prewitt Filter)

프리윗 필터는 x축과 y축의 각 방향으로 차분을 세 번 계산하여 경계를 검출하는 필터입니다. 프리윗 필터는 상하/좌우 경계는 뚜렷하게 잘 검출하지만 대각선 검출이 약합니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig
# 프리윗 마스크를 적용한 경계 검출 (edge_prewitt.py)

import cv2
import numpy as np

file_name = "../img/sudoku.jpg"
img = cv2.imread(file_name)

# 프리윗 커널 생성
gx_k = np.array([[-1,0,1], [-1,0,1],[-1,0,1]])
gy_k = np.array([[-1,-1,-1],[0,0,0], [1,1,1]])

# 프리윗 커널 필터 적용
edge_gx = cv2.filter2D(img, -1, gx_k)
edge_gy = cv2.filter2D(img, -1, gy_k)

# 결과 출력
merged = np.hstack((img, edge_gx, edge_gy, edge_gx+edge_gy))
cv2.imshow('prewitt', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

확실히 기본 미분 필터나 로버츠 교차 필터에 비해 상하/좌우 경계 검출 강도가 강합니다. 마지막은 상하/좌우 경계 검출 필터로 필터링한 결과를 합친 것입니다.

소벨 필터 (Sobel Filter)

소벨 필터는 중심 픽셀의 차분 비중을 두 배로 준 필터입니다. 따라서 소벨 필터는 x축, y축, 대각선 방향의 경계 검출에 모두 강합니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

앞서 설명한 로버츠 필터와 프리윗 필터는 현재는 거의 쓰이지 않습니다. 반면 소벨 필터는 실무적으로도 쓰이므로 OpenCV에서 별도의 함수를 제공합니다.

  • dst = cv2.Sobel(src, ddepth, dx, dy, dst, ksize, scale, delta, borderType)
    src: 입력 영상
    ddepth: 출력 영상의 dtype (-1: 입력 영상과 동일)
    dx, dy: 미분 차수 (0, 1, 2 중 선택, 둘 다 0일 수는 없음)
    ksize: 커널의 크기 (1, 3, 5, 7 중 선택)
    scale: 미분에 사용할 계수
    delta: 연산 결과에 가산할 값

아래는 소벨 필터를 적용한 예제 코드입니다.

# 소벨 마스크를 적용한 경계 검출 (edge_sobel.py)

import cv2
import numpy as np

img = cv2.imread("../img/sudoku.jpg")

# 소벨 커널을 직접 생성해서 엣지 검출 ---①
## 소벨 커널 생성
gx_k = np.array([[-1,0,1], [-2,0,2],[-1,0,1]])
gy_k = np.array([[-1,-2,-1],[0,0,0], [1,2,1]])
## 소벨 필터 적용
edge_gx = cv2.filter2D(img, -1, gx_k)
edge_gy = cv2.filter2D(img, -1, gy_k)

# 소벨 API를 생성해서 엣지 검출
sobelx = cv2.Sobel(img, -1, 1, 0, ksize=3)
sobely = cv2.Sobel(img, -1, 0, 1, ksize=3) 

# 결과 출력
merged1 = np.hstack((img, edge_gx, edge_gy, edge_gx+edge_gy))
merged2 = np.hstack((img, sobelx, sobely, sobelx+sobely))
merged = np.vstack((merged1, merged2))
cv2.imshow('sobel', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

소벨 필터를 직접 생성하여 필터링을 적용해보고, cv2.Sobel() 함수로도 필터링을 적용해봤습니다. 두 결과는 동일한 것을 볼 수 있습니다.

샤르 필터 (Scharr Filter)

소벨 필터는 커널의 중심에서 멀어질수록 엣지 방향성의 정확도가 떨어집니다. 이를 개선한 필터가 샤르 필터입니다. 샤를 필터를 위한 컨볼루션 커널은 아래와 같습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig
  • dst = cv2.Scharr(src, ddepth, dx, dy, dst, scale, delta, borderType)
    ksize가 없다는 것을 제외하면 모든 파라미터는 cv2.Sobel()과 동일합니다.
# 샤르 마스크를 적용한 경계 검출 (edge_scharr.py)

import cv2
import numpy as np

img = cv2.imread("../img/sudoku.jpg")

# 샤르 커널을 직접 생성해서 엣지 검출 ---①
gx_k = np.array([[-3,0,3], [-10,0,10],[-3,0,3]])
gy_k = np.array([[-3,-10,-3],[0,0,0], [3,10,3]])
edge_gx = cv2.filter2D(img, -1, gx_k)
edge_gy = cv2.filter2D(img, -1, gy_k)

# 샤르 API로 엣지 검출 ---②
scharrx = cv2.Scharr(img, -1, 1, 0)
scharry = cv2.Scharr(img, -1, 0, 1)

# 결과 출력
merged1 = np.hstack((img, edge_gx, edge_gy))
merged2 = np.hstack((img, scharrx, scharry))
merged = np.vstack((merged1, merged2))
cv2.imshow('Scharr', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

라플라시안 필터 (Laplacian Filter)

라플라시안 필터는 2차 미분을 적용한 필터입니다. 경계를 더 제대로 검출할 수 있습니다.

  • dst = cv2.Laplacian(src, ddepth, dst, ksize, scale, delta, borderType)
    파라미터는 cv2.Sobel()과 동일합니다.
# 라플라시안 마스크를 적용한 경계 검출 (edge_laplacian.py)

import cv2
import numpy as np

img = cv2.imread("../img/sudoku.jpg")

# 라플라시안 필터 적용 ---①
edge = cv2.Laplacian(img, -1)

# 결과 출력
merged = np.hstack((img, edge))
cv2.imshow('Laplacian', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

확실히 경계가 뚜렷하네요.

출처

https://bkshin.tistory.com/entry/OpenCV-1-%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%9C%EB%8A%94-OpenCV-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8?category=1148027 

캐니 엣지 (Canny Edge)

캐니 엣지는 지금까지 살펴본 것처럼 한 가지 필터만 사용하는 것이 아니라 다음의 4단계 알고리즘에 따라 경계를 검출합니다. 

1. 노이즈 제거: 5 x 5 가우시안 블러링 필터로 노이즈 제거
2. 경계 그레디언트 방향 계산: 소벨 필터로 경계 및 그레디언트 방향 검출
3. 비최대치 억제(Non-Maximum Suppression): 그레디언트 방향에서 검출된 경계 중 가장 큰 값만 선택하고 나머지는 제거
4. 이력 스레시홀딩: 두 개의 경계 값(Max, Min)을 지정해서 경계 영역에 있는 픽셀들 중 큰 경계 값(Max) 밖의 픽셀과 연결성이 없는 픽셀 제거

OpenCV에서 제공하는 캐니 엣지는 함수는 아래와 같습니다.

  • edges = cv2.Canny(img, threshold1, threshold2, edges, apertureSize, L2gardient)
    img: 입력 영상
    threshold1, threshold2: 이력 스레시홀딩에 사용할 Min, Max 값
    apertureSize: 소벨 마스크에 사용할 커널 크기
    L2gradient: 그레디언트 강도를 구할 방식 (True: 제곱 합의 루트 False: 절댓값의 합)
    edges: 엣지 결과 값을 갖는 2차원 배열
# 캐니 엣지 검출 (edge_canny.py)

import cv2, time
import numpy as np

img = cv2.imread("../img/sudoku.jpg")

# 케니 엣지 적용 
edges = cv2.Canny(img,100,200)

# 결과 출력
cv2.imshow('Original', img)
cv2.imshow('Canny', edges)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

확실히 하나의 필터를 적용한 것보다 경계 검출이 잘 됩니다. 그만큼 많이 쓰이는 필터입니다.

지금까지 여러 필터를 활용하여 경계를 검출하는 방법에 대해 알아봤습니다.

이번 포스팅에서는 모폴로지라는 개념에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/06.filter

모폴로지(morphology)란 '형태학'이라는 뜻입니다. 이는 영상 분야에서 노이즈 제거, 구멍 채우기, 끊어진 선 이어 붙이기 등에 쓰이는 형태학적 연산을 말합니다. 모폴로지 연산은 검은색과 흰색으로만 구성되어 있는 바이너리(binary) 이미지에 적용할 수 있습니다. 모폴로지 연산으로는 침식, 팽창, 열림, 닫힘이 있는데 이에 대해 차례대로 배워보겠습니다.

침식 연산

침식(erosion)이란 말 그대로 형태를 깎아 내는 것입니다. 따라서 침식 연산은 이미지를 깎아 내는 연산을 뜻합니다. 침식 연산을 위해서는 구조화 요소 커널(structuring element kernel)이라는 0과 1로 구성된 커널이 필요합니다. 구조화 요소 커널은 1이 채워진 모양에 따라 사각형, 타원형, 십자형 등으로 사용할 수 있습니다.

침식 연산은 구조화 요소 커널을 입력 이미지에 적용해서 1로 채워진 영역을 온전히 올려 놓을 수 없으면 해당 픽셀을 0으로 변경합니다. 아래는 십자형 구조화 요소 커널로 침식 연산을 하는 과정을 보여줍니다. 

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: http://blog.daum.net/shksjy/314

A 이미지에서 흰색 배경은 0이고, 하늘색 전경은 1이라고 합시다. B는 십자형 구조화 요소 커널입니다. 가운데를 포함해서 회색 부분이 다 1로 구성되어 있다고 보면 됩니다. 이때 십자형 구조화 요소 커널의 중심부(빨간 점 부분)는 A 이미지의 파란색 부분을 쭉 훑습니다. 한 칸 한 칸 훑으면서 구조화 요소 커널이 A 이미지의 하늘색 부분과 완전히 겹치지 않을 때는 0으로 변경합니다. 완전히 겹치면 1로 그대로 둡니다. 오른쪽은 침식 연산의 결과입니다. 원래 하늘색 부분이 모두 1이었는데, 침식 연산 결과 빨간 1이 적혀 있는 부분만 1로 남아 있고 나머지 부분은 모두 0으로 변경됩니다. 원본 이미지보다 조금 깎인 것을 볼 수 있습니다.

구조화 요소 커널 생성을 위한 함수는 다음과 같습니다.

  • cv2.getStructuringElement(shape, ksize, anchor)shape: 구조화 요소 커널 모양 (cv2.MORPH_RECT: 사각형, cv2.MORPH_EPLIPSE: 타원형, cv2.MORPH_CROSS: 십자형)ksize: 커널 크기anchor(optional): 구조화 요소의 기준점, cv2.MORPH_CROSS에만 의미 있으며 기본 값은 중심점 (-1, -1)

위 함수로 생성한 구조화 요소 커널로 침식 연산을 수행하는 함수는 다음과 같습니다.

  • dst = cv2.erode(src, kernel, anchor, iterations, borderType, borderValue)src: 입력 영상, 바이너리kernel: 구조화 요소 커널anchor(optional): cv2.getStructuringElement()와 동일iterations(optional): 침식 연산 적용 반복 횟수boderType(optional): 외곽 영역 보정 방법 boderValue(optional): 외곽 영역 보정 값

침식 연산은 큰 물체의 주변을 깎는 기능을 합니다. 더불어 작은 물체는 아예 없애버리므로 노이즈 제거 효과도 있고, 원래는 떨어져 있는 물체인데 겹쳐 있는 것을 서로 떼어내는 데도 효과적입니다.

# 침식 연산 (morph_erode.py)

import cv2
import numpy as np

img = cv2.imread('../img/morph_dot.png')

# 구조화 요소 커널, 사각형 (3x3) 생성 ---①
k = cv2.getStructuringElement(cv2.MORPH_RECT, (3,3))
# 침식 연산 적용 ---②
erosion = cv2.erode(img, k)

# 결과 출력
merged = np.hstack((img, erosion))
cv2.imshow('Erode', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

침식 연산을 위한 구조화 요소 커널은 3 x 3의 사각형 모양으로 생성했습니다. 결과는 보시는 바와 같이 노이즈가 제거되었고, 글씨가 전반적으로 가늘어졌습니다.

팽창 연산

팽창(dilatation)은 침식과 반대로 물체의 주변을 확장하는 연산입니다. 연산 방법도 반대입니다. 침식 연산은 구조화 요소 커널이 입력 영상에서 1로 채워진 영역과 완전히 겹치지 않으면 0으로 변경했습니다. 그러나 팽창 연산은 이와 반대로 완전히 겹치지 않으면 1로 변경합니다. 아래 예시에서 구조화 요소 커널은 십자형이 아님에 유의하기 바랍니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

팽창을 위한 함수는 아래와 같습니다.

  • dst = cv2.dilate(src, kernel, dst, anchor, iterations, bordeType, borderValue)
    모든 파라미터는 cv2.erode()와 동일합니다.
# 팽창 연산 (morph_dilate.py)

import cv2
import numpy as np

img = cv2.imread('../img/morph_hole.png')

# 구조화 요소 커널, 사각형 (3x3) 생성 ---①
k = cv2.getStructuringElement(cv2.MORPH_RECT, (3,3))
# 팽창 연산 적용 ---②
dst = cv2.dilate(img, k)

# 결과 출력
merged = np.hstack((img, dst))
cv2.imshow('Dilation', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

실행 결과 글씨가 더 팽창되긴 했지만 글씨 안에 있던 검정색 노이즈가 제거된 것을 볼 수 있습니다. 흰색 글씨가 바깥으로도 안으로도 팽창이 되어 노이즈는 제거되고 더 뚱뚱해진 거라고 생각하시면 됩니다.

열림, 닫힘, 그레디언트, 탑햇, 블랙햇 연산

침식은 어두운 부분의 노이즈를 제거하는 효과가 있고 팽창은 밝은 부분의 노이즈를 제거하는 효과가 있다는 것을 살펴봤습니다. 노이즈 제거 효과는 좋으나 원래 모양이 홀쭉해지거나 뚱뚱해지는 변형이 일어납니다. 하지만 침식과 팽창의 연산을 조합하면 원래의 모양을 유지하면서 노이즈를 제거하는 효과를 거둘 수 있습니다.

침식 연산 후 팽창 연산을 적용하는 것을 열림(opening) 연산이라고 하고, 팽창 연산 후 침식 연산을 적용하는 것을 닫힘(closing) 연산이라고 합니다. 열림 연산은 주변보다 밝은 노이즈를 제거하는데 효과적입니다. 또한 맞닿아 있는 것처럼 보이는 독립된 개체를 분리하거나 돌출된 모양을 제거하는 데 효과적입니다. 반면, 닫힘 연산은 주변보다 어두운 노이즈를 제거하는데 효과적이면서 끊어져 보이는 개체를 연결하거나 구멍을 메우는 데 효과적입니다. 

열림 = 침식 + 팽창
닫힘 = 팽창 + 침식

팽창 연산을 적용한 이미지에서 침식 연산을 적용한 이미지를 빼면 경계 픽셀만 얻게 되는데, 이는 앞서 살펴본 경계 검출과 비슷합니다. 이런 연산을 그레디언트(gradient) 연산이라고 합니다.

그레디언트 = 팽창 - 침식

또한, 원본에서 열림 연산 적용 결과를 빼면 값이 크게 튀는 밝은 영역을 강조할 수 있고, 닫힘 연산 적용 결과에서 원본을 빼면 어두운 부분을 강조할 수 있습니다. 이것을 각각 탑햇(top hat)과 블랙햇(black hat) 연산이라고 합니다.

탑햇 = 원본 - 열림
블랙햇 = 닫힘 - 원본

OpenCV는 열림, 닫힘, 그레디언트, 탑햇, 블랙햇 연산을 위해서 아래의 함수를 제공합니다.

  • dst = cv2.morphologyEx(src, op, kernel, dst, anchor, iteration, borderType, borderValue)
    src: 입력 영상
    op: 모폴로지 연산 종류 (cv2.MORPH_OPEN: 열림 연산, cv2.MORPH_COLSE: 닫힘 연산, cv2.MORPH_GRADIENT: 그레디언트 연산, cv2.MORPH_TOPHAT: 탑햇 연산, cv2.MORPH_BLACKHAT: 블랙햇 연산)
    kernel: 구조화 요소 커널
    dst(optional): 결과 영상
    anchor(optional): 커널의 기준점iteration(optional): 연산 반복 횟수borderType(optional): 외곽 영역 보정 방법borderValue(optional): 외곽 영역 보정 값
# 열림과 닫힘 연산으로 노이즈 제거 (morph_open_close.py)

import cv2
import numpy as np

img1 = cv2.imread('../img/morph_dot.png', cv2.IMREAD_GRAYSCALE)
img2 = cv2.imread('../img/morph_hole.png', cv2.IMREAD_GRAYSCALE)    

# 구조화 요소 커널, 사각형 (5x5) 생성 ---①
k = cv2.getStructuringElement(cv2.MORPH_RECT, (5,5))
# 열림 연산 적용 ---②
opening = cv2.morphologyEx(img1, cv2.MORPH_OPEN, k)
# 닫힘 연산 적용 ---③
closing = cv2.morphologyEx(img2, cv2.MORPH_CLOSE, k)

# 결과 출력
merged1 = np.hstack((img1, opening))
merged2 = np.hstack((img2, closing))
merged3 = np.vstack((merged1, merged2))
cv2.imshow('opening, closing', merged3)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

앞서 살펴본 팽창 및 침식과 다르게 글씨의 굵기에는 변함이 없으면서 노이즈는 효과적으로 제거했습니다.

아래 코드는 팽창 연산에서 침식 연산을 뺀 그레디언트 연산을 적용한 예제입니다.

# 모폴로지 그레이언트 (morph_gradient.py)

import cv2
import numpy as np

img = cv2.imread('../img/morphological.png')

# 구조화 요소 커널, 사각형 (3x3) 생성 ---①
k = cv2.getStructuringElement(cv2.MORPH_RECT, (3,3))
# 열림 연산 적용 ---②
gradient = cv2.morphologyEx(img, cv2.MORPH_GRADIENT, k)

# 결과 출력
merged = np.hstack((img, gradient))
cv2.imshow('gradient', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

보시는 바와 같이 경계를 검출하는 효과가 있습니다.

아래는 달 이미지에 탑햇, 블랙햇 연산을 적용한 것입니다.

# 모폴로지 탑햇, 블랙햇 연산 (morph_hat.py)

import cv2
import numpy as np

img = cv2.imread('../img/moon_gray.jpg')

# 구조화 요소 커널, 사각형 (5x5) 생성 ---①
k = cv2.getStructuringElement(cv2.MORPH_RECT, (9,9))
# 탑햇 연산 적용 ---②
tophat = cv2.morphologyEx(img, cv2.MORPH_TOPHAT, k)
# 블랫햇 연산 적용 ---③
blackhat = cv2.morphologyEx(img, cv2.MORPH_BLACKHAT, k)

# 결과 출력
merged = np.hstack((img, tophat, blackhat))
cv2.imshow('tophat blackhat', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

이번 포스팅에서는 이미지 피라미드에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/06.filter

이미지 피라미드(image pyramid)란 이미지의 크기를 피라미드처럼 단계적으로 확대하거나 축소하는 작업을 말합니다. 

가우시안 피라미드

가우시안 필터를 적용한 뒤 이미지 피라미드를 구성하는 것을 가우시안 피라미드(gaussian pyramid)라고 합니다. OpenCV에서는 아래와 같은 가우시안 피라미드 함수를 제공합니다.

  • dst = cv2.pyrDown(src, dst, dstsize, borderType)
  • dst = cv2.pyrUp(src, dst, dstsize, borderType)src: 입력 영상dst: 결과 영상distsize: 결과 영상 크기borderType: 외곽 보정 방식

cv2.pyrDown()은 가우시안 필터를 적용한 뒤 모든 짝수 행과 열을 삭제해 입력 영상의 1/4 크기로 축소합니다. 반면 cv2.pyrUp()은 0으로 채워진 짝수 행과 열을 새롭게 삽입하고 나서 가우시안 필터를 적용해 주변 픽셀과 비슷하게 만드는 방법으로 크기는 4배 확대합니다.

# 가우시안 이미지 피라미드 (pyramid_gaussian.py)

import cv2

img = cv2.imread('../img/yeosu_small.jpg')

# 가우시안 이미지 피라미드 축소
smaller = cv2.pyrDown(img) # img x 1/4
# 가우시안 이미지 피라미드 확대
bigger = cv2.pyrUp(img) # img x 4

# 결과 출력
cv2.imshow('img', img)
cv2.imshow('pyrDown', smaller)
cv2.imshow('pyrUp', bigger)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

중간 크기의 이미지가 원본 이미지입니다. 이미지 피라미드로 축소하거나 확대한 이미지는 화질이 많이 떨어지는 것을 볼 수 있습니다.

라플라시안 피라미드

cv2.pyUp() 함수로 이미지를 확대하면 0으로 채워진 행과 열이 새롭게 삽입되므로 원본 이미지보다 화질이 떨어집니다. 따라서 cv2.pyDown() 함수를 적용한 뒤 다시 cv2.pyUp()을 하면 원본 이미지보다 화질이 많이 떨어집니다. 이런 문제점을 개선한 방식이 라플라시안 피라미드(laplacian pyramid)입니다. 

# 라플라시안 피라미드로 영상 복원 (pyramid_laplacian.py)

import cv2
import numpy as np

img = cv2.imread('../img/taekwonv1.jpg')

# 원본 영상을 가우시안 피라미드로 축소
smaller = cv2.pyrDown(img)
# 축소한 영상을 가우시안 피라미드로 확대
bigger = cv2.pyrUp(smaller)

# 원본에서 확대한 영상 빼기
laplacian = cv2.subtract(img, bigger)
# 확대 한 영상에 라플라시안 영상 더해서 복원
restored = bigger + laplacian

# 결과 출력 (원본 영상, 라플라시안, 확대 영상, 복원 영상)
merged = np.hstack((img, laplacian, bigger, restored))
cv2.imshow('Laplacian Pyramid', merged)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

라플라시안 피라미드는 cv2.pyUp() 함수를 적용한 이미지와 원본 이미지의 차를 cv2.pyUp() 함수를 적용한 이미지에 더해주는 방식으로 구현됩니다. 그냥 cv2.pyUp()을 적용한 이미지가 세 번째 이미지인데 화질이 많이 떨어지는 것을 볼 수 있습니다. 하지만 라플라시안 피라미드를 적용한 네 번째 이미지는 원본과 같이 또렷히 복구가 되었습니다.

이번 포스팅에서는 블러링을 활용한 모자이크 처리와 이미지에 스케치 효과를 적용하는 방법에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/06.filter

블러링을 활용한 모자이크 처리

마우스로 드래그하여 선택한 부분을 블러링 효과로 모자이크 처리를 해보겠습니다. 마우스 드래그 후 엔터를 눌러주면 됩니다.

# 블러링을 활용한 모자이크 (workshop_mosaic2.py)

import cv2

ksize = 30              # 블러 처리에 사용할 커널 크기
win_title = 'mosaic'    # 창 제목
img = cv2.imread('../img/taekwonv1.jpg')    # 이미지 읽기

while True:
    x,y,w,h = cv2.selectROI(win_title, img, False) # 관심영역 선택
    if w > 0 and h > 0:         # 폭과 높이가 음수이면 드래그 방향이 옳음 
        roi = img[y:y+h, x:x+w]   # 관심영역 지정
        roi = cv2.blur(roi, (ksize, ksize)) # 블러(모자이크) 처리
        img[y:y+h, x:x+w] = roi   # 원본 이미지에 적용
        cv2.imshow(win_title, img)
    else:
        break
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

이미지에 스케치 효과 적용하기

카메라로 찍은 이미지에 2D 스케치 효과를 적용해보겠습니다. 세부적인 코드는 지금까지 배웠던 것들입니다.

# 스케치 효과 (workshop_painting_cam.py)

import cv2
import numpy as np

# 카메라 장치 연결
cap = cv2.VideoCapture(0)   
while cap.isOpened():
    # 프레임 읽기
    ret, frame = cap.read()
    # 속도 향상을 위해 영상크기를 절반으로 축소
    frame = cv2.resize(frame, None, fx=0.5, fy=0.5, \
                        interpolation=cv2.INTER_AREA)
    if cv2.waitKey(1) == 27: # esc키로 종료
        break
    # 그레이 스케일로 변경    
    img_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    # 잡음 제거를 위해 가우시안 플러 필터 적용(라플라시안 필터 적용 전에 필수)
    img_gray = cv2.GaussianBlur(img_gray, (9,9), 0)
    # 라플라시안 필터로 엣지 검출
    edges = cv2.Laplacian(img_gray, -1, None, 5)
    # 스레시홀드로 경계 값 만 남기고 제거하면서 화면 반전(흰 바탕 검은 선)
    ret, sketch = cv2.threshold(edges, 70, 255, cv2.THRESH_BINARY_INV)
    
    # 경계선 강조를 위해 침식 연산
    kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3,3))
    sketch = cv2.erode(sketch, kernel)
    # 경계선 자연스럽게 하기 위해 미디언 블러 필터 적용
    sketch = cv2.medianBlur(sketch, 5)
    # 그레이 스케일에서 BGR 컬러 스케일로 변경
    img_sketch = cv2.cvtColor(sketch, cv2.COLOR_GRAY2BGR)

    # 컬러 이미지 선명선을 없애기 위해 평균 블러 필터 적용
    img_paint = cv2.blur(frame, (10,10) )
    # 컬러 영상과 스케치 영상과 합성
    img_paint = cv2.bitwise_and(img_paint, img_paint, mask=sketch)
    
    # 결과 출력
    merged = np.hstack((img_sketch, img_paint))
    cv2.imshow('Sketch Camera', merged)
    
cap.release()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

이번 포스팅에서는 영상 분할 방법 중 하나인 컨투어에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/07.segmentation

컨투어(Contour)

컨투어(contour)는 등고선을 의미합니다. 등고선은 지형의 높이가 같은 영역을 하나의 선으로 표시한 것입니다. 영상에서 컨투어를 그리면 모양을 쉽게 인식할 수 있습니다. OpenCV에서 제공하는 컨투어 함수는 다음과 같습니다.

  • dst, contours, hierarchy = cv2.findContours(src, mode, method, contours, hierarchy, offset)src: 입력 영상, 검정과 흰색으로 구성된 바이너리 이미지mode: 컨투어 제공 방식 (cv2.RETR_EXTERNAL: 가장 바깥쪽 라인만 생성, cv2.RETR_LIST: 모든 라인을 계층 없이 생성, cv2.RET_CCOMP: 모든 라인을 2 계층으로 생성, cv2.RETR_TREE: 모든 라인의 모든 계층 정보를 트리 구조로 생성)method: 근사 값 방식 (cv2.CHAIN_APPROX_NONE: 근사 없이 모든 좌표 제공, cv2.CHAIN_APPROX_SIMPLE: 컨투어 꼭짓점 좌표만 제공, cv2.CHAIN_APPROX_TC89_L1: Teh-Chin 알고리즘으로 좌표 개수 축소, cv2.CHAIN_APPROX_TC89_KC0S: Teh-Chin 알고리즘으로 좌표 개수 축소)contours(optional): 검출한 컨투어 좌표 (list type)hierarchy(optional): 컨투어 계층 정보 (Next, Prev, FirstChild, Parent, -1 [해당 없음])offset(optional): ROI 등으로 인해 이동한 컨투어 좌표의 오프셋

위 함수로 컨투어를 찾아낸 다음 아래 함수로 컨투어를 그려줄 수 있습니다.

  • cv2.drawContours(img, contours, contourIdx, color, thickness)img: 입력 영상contours: 그림 그릴 컨투어 배열 (cv2.findContours() 함수의 반환 결과를 전달해주면 됨)contourIdx: 그림 그릴 컨투어 인덱스, -1: 모든 컨투어 표시color: 색상 값thickness: 선 두께, 0: 채우기

cv2.darwContours()는 실제로 컨투어 선을 그리는 함수입니다. img영상에 contours 배열에 있는 컨투어 중 contourIdx에 해당하는 컨투어를 color 색상과 thickness 두께로 선을 그립니다. 위 두 함수를 활용하여 컨투어를 그려보겠습니다.

# 컨투어 찾기와 그리기 (cntr_find.py)

import cv2
import numpy as np

img = cv2.imread('../img/shapes.png')
img2 = img.copy()

# 그레이 스케일로 변환 ---①
imgray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 스레시홀드로 바이너리 이미지로 만들어서 검은배경에 흰색전경으로 반전 ---②
ret, imthres = cv2.threshold(imgray, 127, 255, cv2.THRESH_BINARY_INV)

# 가장 바깥쪽 컨투어에 대해 모든 좌표 반환 ---③
im2, contour, hierarchy = cv2.findContours(imthres, cv2.RETR_EXTERNAL, \
                                                 cv2.CHAIN_APPROX_NONE)
# 가장 바깥쪽 컨투어에 대해 꼭지점 좌표만 반환 ---④
im2, contour2, hierarchy = cv2.findContours(imthres, cv2.RETR_EXTERNAL, \
                                                cv2.CHAIN_APPROX_SIMPLE)
# 각각의 컨투의 갯수 출력 ---⑤
print('도형의 갯수: %d(%d)'% (len(contour), len(contour2)))

# 모든 좌표를 갖는 컨투어 그리기, 초록색  ---⑥
cv2.drawContours(img, contour, -1, (0,255,0), 4)
# 꼭지점 좌표만을 갖는 컨투어 그리기, 초록색  ---⑦
cv2.drawContours(img2, contour2, -1, (0,255,0), 4)

# 컨투어 모든 좌표를 작은 파랑색 점(원)으로 표시 ---⑧
for i in contour:
    for j in i:
        cv2.circle(img, tuple(j[0]), 1, (255,0,0), -1) 

# 컨투어 꼭지점 좌표를 작은 파랑색 점(원)으로 표시 ---⑨
for i in contour2:
    for j in i:
        cv2.circle(img2, tuple(j[0]), 1, (255,0,0), -1) 

# 결과 출력 ---⑩
cv2.imshow('CHAIN_APPROX_NONE', img)
cv2.imshow('CHAIN_APPROX_SIMPLE', img2)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

왼쪽은 파라미터로 cv2.CHAIN_APPROX_SIMPLE을 사용해서 꼭짓점만 표시를 했고, 오른쪽은 cv2.CHAIN_APPROX_NONE을 사용해서 모든 좌표에 컨투어를 그렸습니다.

다음으로는 트리 계층의 컨투어를 그려보겠습니다.

# 컨투어 계층 트리 (cntr_hierachy.py)

import cv2
import numpy as np

# 영상 읽기
img = cv2.imread('../img/shapes_donut.png')
img2 = img.copy()
# 바이너리 이미지로 변환
imgray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, imthres = cv2.threshold(imgray, 127, 255, cv2.THRESH_BINARY_INV)

# 가장 바깥 컨투어만 수집   --- ①
im2, contour, hierarchy = cv2.findContours(imthres, cv2.RETR_EXTERNAL, \
                                                cv2.CHAIN_APPROX_NONE)
# 컨투어 갯수와 계층 트리 출력 --- ②
print(len(contour), hierarchy)

# 모든 컨투어를 트리 계층 으로 수집 ---③
im2, contour2, hierarchy = cv2.findContours(imthres, cv2.RETR_TREE, \
                                            cv2.CHAIN_APPROX_SIMPLE)
# 컨투어 갯수와 계층 트리 출력 ---④
print(len(contour2), hierarchy)

# 가장 바깥 컨투어만 그리기 ---⑤
cv2.drawContours(img, contour, -1, (0,255,0), 3)
# 모든 컨투어 그리기 ---⑥
for idx, cont in enumerate(contour2): 
    # 랜덤한 컬러 추출 ---⑦
    color = [int(i) for i in np.random.randint(0,255, 3)]
    # 컨투어 인덱스 마다 랜덤한 색상으로 그리기 ---⑧
    cv2.drawContours(img2, contour2, idx, color, 3)
    # 컨투어 첫 좌표에 인덱스 숫자 표시 ---⑨
    cv2.putText(img2, str(idx), tuple(cont[0][0]), cv2.FONT_HERSHEY_PLAIN, \
                                                            1, (0,0,255))

# 화면 출력
cv2.imshow('RETR_EXTERNAL', img)
cv2.imshow('RETR_TREE', img2)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

파라미터로 cv2.RETR_EXTERNAL을 전달할 때는 그림의 외곽 부분에만 컨투어를 그립니다. 하지만 cv2.RETR_TREE를 전달할 때는 모든 경계에 컨투어를 그립니다. 이를 트리 계층 컨투어라고 합니다. 위 그림에서는 보기 쉽게 다양한 색깔로 컨투어를 그렸습니다.

위 코드 중

print(len(contour2), hierarchy)

의 출력 결과를 살펴보겠습니다. cv2.findContours() 함수를 호출하면 컨투어 좌표뿐만 아니라 hierarchy를 다음과 같이 출력합니다. 아래는 contour2, 즉 위 출력 그림에서 오른쪽 결과(트리 계층 컨투어)에 해당하는 hierarchy입니다.

인덱스 다음(Next) 이전(Prev) 자식(First Child) 부모(Parent)
0 2 -1 1 -1
1 -1 -1 -1 0
2 4 0 3 -1
3 -1 -1 -1 2
4 -1 2 5 -1
5 -1 -1 -1 4

요소 값 중 -1은 의미 없음을 나타냅니다. 우선, 0번째 행을 보겠습니다. 0번째 행은 첫 번째 도형의 컨투어를 의미합니다. 이는 위 두 번째 그림에서 왼쪽의 삼각형 외곽을 뜻합니다. 0번째 행의 Next, Prev, First Child, Parent는 각각 2, -1, 1, -1입니다. Prev와 Parent는 -1이므로 아무 의미가 없다는 뜻입니다. 즉, 삼각형 외곽 컨투어의 기준에는 이전 도형이 없고, 부모 도형도 없다는 뜻입니다. 그러나 Next와 First Child는 각 2와 1입니다. 이 말은 다음 도형은 2행이고, 자식은 1행이라는 것입니다. 2행인 4, 0 ,3 -1을 이루고 있는 컨투어는 맨 오른쪽 원 도형의 외곽입니다. 1행인 -1, -1, -1, 0은 왼쪽 삼각형의 내부 컨투어입니다. 자연스럽게 내부 삼각형의 부모는 0입니다. 2행인 4, 0 ,3 -1을 이루고 있는 컨투어의 다음 도형은 4행인 사각형 외곽이고, 이전은 0행인 삼각형 외곽이며, 자식은 3행인 원 내부입니다. 이런 식으로 컨투어 계층 정보(hierarchy)를 보면 외곽 요소와 자식 요소를 순회할 수 있습니다. 최외곽 컨투어만 골라내려면 부모 항목이 -1인 행만 찾으면 되고, 그것이 이 예제에서는 도형의 개수와 같습니다.

컨투어를 감싸는 도형 그리기

OpenCV를 활용하면 컨투어를 감싸는 도형을 그릴 수도 있습니다. 컨투어를 감싸는 도형을 그리는 아래 함수들에 대해 먼저 알아보겠습니다.

  • x, y, w, h = cv2.boundingRect(contour): 좌표를 감싸는 사각형 반환x, y: 사각형의 왼쪽 상단 좌표w, h: 사각형의 폭과 높이
  • rotateRect = cv2.minAreaRect(contour): 좌표를 감싸는 최소한의 사각형 계산
  • vertex = cv2.boxPoints(rotateRect): rotateRect로부터 꼭짓점 좌표 계산vertex: 4개의 꼭짓점 좌표, 소수점 포함이므로 정수 변환 필요
  • center, radius = cv2.minEnclosingCircle(contour): 좌표를 감싸는 최소한의 동그라미 계산center: 원점 좌표(x, y)radius: 반지름
  • area, triangle = cv2.minEnclosingTriangle(points): 좌표를 감싸는 최소한의 삼각형 게산area: 넓이triangle: 3개의 꼭짓점 좌표
  • ellipse = cv2.fitEllipse(points): 좌표를 감싸는 최소한의 타원 계산
  • line = cv2.fitLine(points, distType, param, reps, aeps, line): 중심점을 통과하는 직선 계산distType: 거리 계산 방식 (cv2.DIST_L2, cv2.DIST_L1, cv2.DIST_L12, cv2.DIST_FAIR, cv2.DIST_WELSCH, cv2.DIST_HUBER)param: distType에 전달할 인자, 0 = 최적 값 선택reps: 반지름 정확도, 선과 원본 좌표의 거리, 0.01 권장aeps: 각도 정확도, 0.01 권장line(optional): vx, vy 정규화된 단위 벡터, x0, y0: 중심점 좌표

위 함수를 활용하여 컨투어를 감싸는 다양한 도형을 그려보겠습니다.

# 컨투어를 감싸는 도형 그리기 (cntr_bound_fit.py)

import cv2
import numpy as np

# 이미지 읽어서 그레이스케일 변환, 바이너리 스케일 변환
img = cv2.imread("../img/lightning.png")
imgray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, th = cv2.threshold(imgray, 127,255,cv2.THRESH_BINARY_INV)

# 컨튜어 찾기
im, contours, hr = cv2.findContours(th, cv2.RETR_EXTERNAL, \
                                        cv2.CHAIN_APPROX_SIMPLE)
contr = contours[0]

# 감싸는 사각형 표시(검정색)
x,y,w,h = cv2.boundingRect(contr)
cv2.rectangle(img, (x,y), (x+w, y+h), (0,0,0), 3)

# 최소한의 사각형 표시(초록색)
rect = cv2.minAreaRect(contr)
box = cv2.boxPoints(rect)   # 중심점과 각도를 4개의 꼭지점 좌표로 변환
box = np.int0(box)          # 정수로 변환
cv2.drawContours(img, [box], -1, (0,255,0), 3)

# 최소한의 원 표시(파랑색)
(x,y), radius = cv2.minEnclosingCircle(contr)
cv2.circle(img, (int(x), int(y)), int(radius), (255,0,0), 2)

# 최소한의 삼각형 표시(분홍색)
ret, tri = cv2.minEnclosingTriangle(contr)
cv2.polylines(img, [np.int32(tri)], True, (255,0,255), 2)

# 최소한의 타원 표시(노랑색)
ellipse = cv2.fitEllipse(contr)
cv2.ellipse(img, ellipse, (0,255,255), 3)

# 중심점 통과하는 직선 표시(빨강색)
[vx,vy,x,y] = cv2.fitLine(contr, cv2.DIST_L2,0,0.01,0.01)
cols,rows = img.shape[:2]
cv2.line(img,(0, 0-x*(vy/vx) + y), (cols-1, (cols-x)*(vy/vx) + y), \
                                                        (0,0,255),2)

# 결과 출력
cv2.imshow('Bound Fit shapes', img)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

까만 번개 이미지 주위를 둘러싼 여러 도형들을 그려봤습니다. 

컨투어 단순화 

지금까지 살펴본 컨투어 함수는 이미지 외곽을 따라 그림을 그려주는 기능을 제공했습니다. 하지만 실생활에서 얻는 대부분의 이미지는 약간의 노이즈가 포함되어 있습니다. 그래서 컨투어를 너무 정확히 그리는 것도 바람직하지 않습니다. 오히려 약간 단순화해 그리는 게 정확하게 그리는 것보다 더 쓸모 있는 경우가 있습니다. OpenCV는 아래와 같은 함수를 통해 근사 값으로 컨투어를 계산해줍니다.

  • approx = cv2.approxPolyDP(contour, epsilon, closed)
    contour: 대상 컨투어 좌표
    epsilon: 근사 값 정확도, 오차 범위
    closed: 컨투어의 닫힘 여부
    approx: 근사 계산한 컨투어 좌표
# 근사 컨투어 (cntr_approximate.py)

import cv2
import numpy as np

img = cv2.imread('../img/bad_rect.png')
img2 = img.copy()

# 그레이스케일과 바이너리 스케일 변환
imgray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 
ret, th = cv2.threshold(imgray, 127, 255, cv2.THRESH_BINARY)

# 컨투어 찾기 ---①
temp, contours, hierachy = cv2.findContours(th, cv2.RETR_EXTERNAL, \
                                     cv2.CHAIN_APPROX_SIMPLE)
contour = contours[0]
# 전체 둘레의 0.05로 오차 범위 지정 ---②
epsilon = 0.05 * cv2.arcLength(contour, True)
# 근사 컨투어 계산 ---③
approx = cv2.approxPolyDP(contour, epsilon, True)

# 각각 컨투어 선 그리기 ---④
cv2.drawContours(img, [contour], -1, (0,255,0), 3)
cv2.drawContours(img2, [approx], -1, (0,255,0), 3)

# 결과 출력
cv2.imshow('contour', img)
cv2.imshow('approx', img2)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

cv2.approxPolyDP() 함수를 활용하면 오른쪽과 같이 요철이 있는 부분은 무시하고 컨투어를 계산해줍니다. 

컨투어를 단순화하는 또 다른 형태는 볼록 선체(convex hull)를 만드는 것입니다. 볼록 선체란 어느 한 부분도 오목하지 않은 도형을 의미합니다. 따라서 볼록 선체는 대상을 완전히 포함하는 외곽 영역을 찾는데 유용합니다.

  • hull = cv2.convexHull(points, hull, clockwise, returnPoints): 볼록 선체 계산
    points: 입력 컨투어
    hull(optional): 볼록 선체 결과
    clockwise(optional): 방향 지정 (True: 시계 방향)
    returnPoints(optional): 결과 좌표 형식 선택 (True: 볼록 선체 좌표 변환, False: 입력 컨투어 중에 볼록 선체에 해당하는 인덱스 반환)
  • retval = cv2.isContourConvex(contour): 볼록 선체 만족 여부 확인
    retval: True인 경우 볼록 선체임
  • defects = cv2.convexityDefects(contour, convexhull): 볼록 선체 결함 찾기
    contour: 입력 컨투어
    convexhull: 볼록 선체에 해당하는 컨투어의 인덱스
    defects: 볼록 선체 결함이 있는 컨투어의 배열 인덱스, N x 1 x 4 배열, [starts, end, farthest, distance]
      start: 오목한 각이 시작되는 컨투어의 인덱스
      end: 오목한 각이 끝나는 컨투어의 인덱스
      farthest: 볼록 선체에서 가장 먼 오목한 지점의 컨투어 인덱스
      distance: farthest와 볼록 선체와의 거리
# 볼록 선체 (cntr_convexhull.py)

import cv2
import numpy as np

img = cv2.imread('../img/hand.jpg')
img2 = img.copy()
# 그레이 스케일 및 바이너리 스케일 변환 ---①
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, th = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV)

# 컨투어 찾기와 그리기 ---②
temp, contours, heiarchy = cv2.findContours(th, cv2.RETR_EXTERNAL, \
                                         cv2.CHAIN_APPROX_SIMPLE)
cntr = contours[0]
cv2.drawContours(img, [cntr], -1, (0, 255,0), 1)

# 볼록 선체 찾기(좌표 기준)와 그리기 ---③
hull = cv2.convexHull(cntr)
cv2.drawContours(img2, [hull], -1, (0,255,0), 1)
# 볼록 선체 만족 여부 확인 ---④
print(cv2.isContourConvex(cntr), cv2.isContourConvex(hull))

# 볼록 선체 찾기(인덱스 기준) ---⑤
hull2 = cv2.convexHull(cntr, returnPoints=False)
# 볼록 선체 결함 찾기 ---⑥
defects = cv2.convexityDefects(cntr, hull2)
# 볼록 선체 결함 순회
for i in range(defects.shape[0]):
    # 시작, 종료, 가장 먼 지점, 거리 ---⑦
    startP, endP, farthestP, distance = defects[i, 0]
    # 가장 먼 지점의 좌표 구하기 ---⑧
    farthest = tuple(cntr[farthestP][0])
    # 거리를 부동 소수점으로 변환 ---⑨
    dist = distance/256.0
    # 거리가 1보다 큰 경우 ---⑩
    if dist > 1 :
        # 빨강색 점 표시 
        cv2.circle(img2, farthest, 3, (0,0,255), -1)
# 결과 이미지 표시
cv2.imshow('contour', img)
cv2.imshow('convex hull', img2)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

왼쪽은 손 모양 이미지에 대한 컨투어입니다. 오른쪽은 볼록 선체를 그려주었고, 또한 볼록 선체의 결점을 빨간 점으로 표시해주었습니다. 

컨투어와 도형 매칭

서로 다른 물체의 컨투어를 비교하면 두 물체가 얼마나 비슷한지 알 수 있습니다. 이는 아래 함수로 간단히 구현할 수 있습니다.

  • retval = cv2.matchShapes(contour1, contour2, method, parameter): 두 개의 컨투어로 도형 매칭
    contour1, contour2: 비교할 두 개의 컨투어
    method: 휴 모멘트 비교 알고리즘 선택 플래그 (cv2.CONTOURS_MATCH_I1, cv2.CONTOURS_MATCH_I2, cv2.CONTOURS_MATCH_I3)parameter: 알고리즘에 전달을 위한 예비 인수로 0으로 고정retval: 두 도형의 닮은 정도 (0=동일, 숫자가 클수록 다름)
# 도형 매칭으로 비슷한 도형 찾기 (contr_matchShape.py)

import cv2
import numpy as np

# 매칭을 위한 이미지 읽기
target = cv2.imread('../img/4star.jpg') # 매칭 대상
shapes = cv2.imread('../img/shapestomatch.jpg') # 여러 도형
# 그레이 스케일 변환
targetGray = cv2.cvtColor(target, cv2.COLOR_BGR2GRAY)
shapesGray = cv2.cvtColor(shapes, cv2.COLOR_BGR2GRAY)
# 바이너리 스케일 변환
ret, targetTh = cv2.threshold(targetGray, 127, 255, cv2.THRESH_BINARY_INV)
ret, shapesTh = cv2.threshold(shapesGray, 127, 255, cv2.THRESH_BINARY_INV)
# 컨투어 찾기
_, cntrs_target, _ = cv2.findContours(targetTh, cv2.RETR_EXTERNAL, \
                                            cv2.CHAIN_APPROX_SIMPLE)
_, cntrs_shapes, _ = cv2.findContours(shapesTh, cv2.RETR_EXTERNAL, \
                                            cv2.CHAIN_APPROX_SIMPLE)

# 각 도형과 매칭을 위한 반복문
matchs = [] # 컨투어와 매칭 점수를 보관할 리스트
for contr in cntrs_shapes:
    # 대상 도형과 여러 도형 중 하나와 매칭 실행 ---①
    match = cv2.matchShapes(cntrs_target[0], contr, cv2.CONTOURS_MATCH_I2, 0.0)
    # 해당 도형의 매칭 점수와 컨투어를 쌍으로 저장 ---②
    matchs.append( (match, contr) )
    # 해당 도형의 컨투어 시작지점에 매칭 점수 표시 ---③
    cv2.putText(shapes, '%.2f'%match, tuple(contr[0][0]),\
                    cv2.FONT_HERSHEY_PLAIN, 1,(0,0,255),1 )
# 매칭 점수로 정렬 ---④
matchs.sort(key=lambda x : x[0])
# 가장 적은 매칭 점수를 얻는 도형의 컨투어에 선 그리기 ---⑤
cv2.drawContours(shapes, [matchs[0][1]], -1, (0,255,0), 3)
cv2.imshow('target', target)
cv2.imshow('Match Shape', shapes)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

왼쪽의 별 모양 도형과 가장 유사한 도형을 오른쪽 세 도형 중 찾는 코드입니다. 매칭 정도를 숫자로 표현했고 (숫자가 작을수록 서로 닮은 도형임) 가장 닮은 도형 주위에 초록색 컨투어를 그려주었습니다.

지금까지 컨투어를 그리는 다양한 방법에 대해 알아봤습니다.

이번 포스팅에서는 허프 변환에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/07.segmentation

허프 변환

허프 변환을 활용해 이미지에서 직선이나 원과 같은 다양한 모양을 인식할 수 있습니다. 여기서는 직선과 원을 검출하는 함수에 대해 배워보겠습니다. 허프 변환에 대한 이론적인 설명은 opencv 한글문서를 참고해주시기 바랍니다. 혹은 한글 위키피디아도 도움이 됩니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: wikipedia (hough transform)

허프 선 변환

이미지는 수많은 픽셀로 구성되어 있습니다. 그 픽셀 중 서로 직선 관계를 갖는 픽셀들만 골라내는 것이 허프 선 변환의 핵심입니다. OpenCV에서는 허프 변환을 위해 아래와 같은 함수를 제공합니다.

  • lines = cv2.HoughLines(img, rho, theta, threshold, lines, srn=0, stn=0, min_theta, max_theta)img: 입력 이미지, 1 채널 바이너리 스케일rho: 거리 측정 해상도, 0~1theta: 각도, 라디안 단위 (np.pi/0~180)threshold: 직선으로 판단할 최소한의 동일 개수 (작은 값: 정확도 감소, 검출 개수 증가 / 큰 값: 정확도 증가, 검출 개수 감소)lines: 검출 결과, N x 1 x 2 배열 (r, Θ)srn, stn: 멀티 스케일 허프 변환에 사용, 선 검출에서는 사용 안 함min_theta, max_theta: 검출을 위해 사용할 최대, 최소 각도

거리와 각도를 얼마나 세밀하게 계산할 것인지를 rho와 theta 파라미터로 조정할 수 있습니다. threshold는 같은 직선에 몇 개의 점이 등장해야 직선으로 판단할지를 나타내는 최소한의 개수를 말합니다. 아래는 직선을 검출하고 기준 좌표에 빨간 점을 찍은 예시입니다.

# 허프 선 검출 (hough_line.py)

import cv2
import numpy as np

img = cv2.imread('../img/sudoku.jpg')
img2 = img.copy()
h, w = img.shape[:2]
# 그레이 스케일 변환 및 엣지 검출 ---①
imgray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(imgray, 100, 200 )
# 허프 선 검출, 직선으로 판단할 최소한의 점은 130개로 지정 ---②
lines = cv2.HoughLines(edges, 1, np.pi/180, 130)
for line in lines: # 검출된 모든 선 순회
    r,theta = line[0] # 거리와 각도
    tx, ty = np.cos(theta), np.sin(theta) # x, y축에 대한 삼각비
    x0, y0 = tx*r, ty*r  #x, y 기준(절편) 좌표
    # 기준 좌표에 빨강색 점 그리기
    cv2.circle(img2, (abs(x0), abs(y0)), 3, (0,0,255), -1)
    # 직선 방정식으로 그리기 위한 시작점, 끝점 계산
    x1, y1 = int(x0 + w*(-ty)), int(y0 + h * tx)
    x2, y2 = int(x0 - w*(-ty)), int(y0 - h * tx)
    # 선그리기
    cv2.line(img2, (x1, y1), (x2, y2), (0,255,0), 1)

#결과 출력    
merged = np.hstack((img, img2))
cv2.imshow('hough line', merged)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

우선 캐니 엣지로 경계값을 검출한 뒤 허프 선 검출을 했습니다.

확률적 허프 선 변환

허프 선 검출은 모든 점에 대해 수많은 선을 그어서 직선을 찾기 때문에 연산량이 무척 많습니다. 이를 개선하기 위한 방법이 확률적 허프 선 변환입니다. 이는 모든 점을 고려하지 않고 무작위로 선정한 픽셀에 대해 허프 변환을 수행하고 점차 그 수를 증가시키는 방법입니다. 다음의 함수로 확률적 허프 선 변환을 수행할 수 있습니다.

  • lines = cv2.HoughLinesP(img, rho, theta, threshold, lines, minLineLength, maxLineGap)
    minLineLength(optional): 선으로 인정할 최소 길이
    maxLineGap(optional): 선으로 판단할 최대 간격
    lines: 검출된 선 좌표, N x 1 x 4 배열 (x1, y1, x2, y2)
    이외의 파라미터는 cv2.HoughLines()와 동일

cv2.HoughLines()의 검출 결과는 r, Θ이지만 cv2.HoughLinesP()의 검출 결과는 선의 시작과 끝 좌표입니다. 이는 확률적으로 선을 검출하므로 당연히 cv2.HoughLines()보다 선 검출이 적습니다. 따라서 엣지를 강하게 하고 threshold를 낮게 지정해주어야 합니다.

# 확률 허프 변환으로 선 검출 (hough_lineP.py)

import cv2
import numpy as np

img = cv2.imread('../img/sudoku.jpg')
img2 = img.copy()
# 그레이 스케일로 변환 및 엣지 검출 ---①
imgray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(imgray, 50, 200 )

# 확율 허프 변환 적용 ---②
lines = cv2.HoughLinesP(edges, 1, np.pi/180, 10, None, 20, 2)
for line in lines:
    # 검출된 선 그리기 ---③
    x1, y1, x2, y2 = line[0]
    cv2.line(img2, (x1,y1), (x2, y2), (0,255,0), 1)

merged = np.hstack((img, img2))
cv2.imshow('Probability hough line', merged)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

확실히 cv2.HoughLines()보다는 선을 잘 검출하지 못 하는 것을 볼 수 있습니다. 하지만 속도는 더 빠를 겁니다. cv2.HoughLinesP()의 반환 값이 선의 시작과 끝 좌표이므로 직선을 그리기가 더 편리합니다.

허프 원 변환

허프 변환을 통해 원을 검출할수도 있습니다.

  • circle = cv2.HoughCircles(img, method, dp, minDist, circles, param1, param2, minRadius, maxRadius)img: 입력 이미지, 1채널 배열method: 검출 방식 선택 (현재 cv2.HOUGH_GRADIENT만 가능)dp: 입력 영상과 경사 누적의 해상도 반비례율, 1: 입력과 동일, 값이 커질수록 부정확minDist: 원들 중심 간의 최소 거리 (0: 에러, 0이면 동심원이 검출 불가하므로)circles(optional): 검출 원 결과, N x 1 x 3 부동 소수점 배열 (x, y, 반지름)param1(optional): 캐니 엣지에 전달할 스레시홀드 최대 값 (최소 값은 최대 값의 2배 작은 값을 전달)param2(optional): 경사도 누적 경계 값 (값이 작을수록 잘못된 원 검출)minRadius, maxRadius(optional): 원의 최소 반지름, 최대 반지름 (0이면 이미지 전체의 크기)

cv2.HoughCircles는 캐니 엣지를 수행하고 나서 소벨 필터를 적용해 엣지의 경사도(gradient)를 누적하는 방법으로 원 검출을 구현했습니다. 그래서 캐니 엣지 및 경사도 누적에 대한 파라미터(param1, param2)가 있는 것입니다.

# 허프 원 검출 (hough_circle.py)

import cv2
import numpy as np

img = cv2.imread('../img/coins_connected.jpg')
# 그레이 스케일 변환 ---①
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 노이즈 제거를 위한 가우시안 블러 ---②
blur = cv2.GaussianBlur(gray, (3,3), 0)
# 허프 원 변환 적용( dp=1.2, minDist=30, cany_max=200 ) ---③
circles = cv2.HoughCircles(blur, cv2.HOUGH_GRADIENT, 1.2, 30, None, 200)
if circles is not None:
    circles = np.uint16(np.around(circles))
    for i in circles[0,:]:
        # 원 둘레에 초록색 원 그리기
        cv2.circle(img,(i[0], i[1]), i[2], (0, 255, 0), 2)
        # 원 중심점에 빨강색 원 그리기
        cv2.circle(img, (i[0], i[1]), 2, (0,0,255), 5)

# 결과 출력
cv2.imshow('hough circle', img)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

동전을 검출하는 코드입니다. cv2.HoughCircles()는 함수 자체적으로 캐니 엣지를 사용하므로 위 코드에서는 가우시안 블러를 통해 노이즈만 제거하고 엣지 검출을 따로 하지 않았습니다. dp의 값은 1에 가까울수록 정확하게 원을 검출합니다. dp 값은 점차 키워가면서 경험적으로 찾아야 합니다.

이전 포스팅에서는 외곽 경계를 이용해서 객체 영역을 분할하는 방법에 대해 알아봤습니다. 하지만 실제 이미지에는 노이즈도 많고, 경계선이 명확하지 않아 객체 영역을 정확히 분할하는 것이 힘든 경우도 있습니다. 그래서 연속된 영역을 찾아 분할하는 방법도 필요합니다. 이번 포스팅에서는 연속 영역 분할에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/07.segmentation

거리 변환 (Distatnce Transformation)

이미지에서 물체 영역을 정확히 파악하기 위해서는 물체 영역의 뼈대를 찾아야 합니다. 뼈대를 검출하는 방법 중 하나가 외곽 경계로부터 가장 멀리 떨어진 곳을 찾는 방법인 거리 변환입니다. OpenCV에는 거리 변환을 해주는 cv2.distanceTransform() 함수가 있습니다.

  • cv2.distanceTransform(src, distanceType, maskSize)
    src: 입력 영상, 바이너리 스케일
    distanceType: 거리 계산 방식 (cv2.DIST_L2, cv2.DIST_L1, cv2.DIST_L12, cv2.DIST_FAIR, cv2.DIST_WELSCH, cv2.DIST_HUBER)
    maskSize: 거리 변환 커널 크기
# 거리 변환으로 전신 스켈레톤 찾기 (distanceTrans.py)

import cv2
import numpy as np

# 이미지를 읽어서 바이너리 스케일로 변환
img = cv2.imread('../img/full_body.jpg', cv2.IMREAD_GRAYSCALE)
_, biimg = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY_INV)

# 거리 변환 ---①
dst = cv2.distanceTransform(biimg, cv2.DIST_L2, 5)
# 거리 값을 0 ~ 255 범위로 정규화 ---②
dst = (dst/(dst.max()-dst.min()) * 255).astype(np.uint8)
# 거리 값에 쓰레시홀드로 완전한 뼈대 찾기 ---③
skeleton = cv2.adaptiveThreshold(dst, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, \
                                                 cv2.THRESH_BINARY, 7, -3)
# 결과 출력
cv2.imshow('origin', img)
cv2.imshow('dist', dst)
cv2.imshow('skel', skeleton)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

왼쪽 이미지는 원본 이미지를 바이너리 스케일로 변환한 이미지입니다. 가운데 이미지는 0 ~ 255 범위로 정규화한 거리 변환 결과입니다. 외곽 경계로부터 멀어질수록 흰색이 짙어지는 걸 볼 수 있습니다. 오른쪽 이미지는 가운데 이미지에서 픽셀 값=255(흰색)에 해당하는 부분만 추출한 것입니다. 이는 이미지의 뼈대라고도 볼 수 있습니다. 경계로부터 가장 멀리 떨어져 있는 부분만 추출한 것입니다. 이를 거리 변환을 통한 뼈대 추출이라고 합니다.

레이블링 (Labeling)

연결된 요소끼리 분리하는 방법 중 레이블링이라는 방법이 있습니다. 아래와 같이 이미지에서 픽셀 값이 0으로 끊어지지 않는 부분끼리 같은 값을 부여해서 분리를 할 수 있습니다. OpenCV에서 제공하는 cv2.connectedComponents() 함수를 활용하면 이를 구현할 수 있습니다. 이 함수는 이미지 전체에서 0으로 끊어지지 않는 부분끼리 같은 값을 부여합니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig


retval, labels = cv2.connectedComponents(src, labels, connectivity=8, ltype)
: 연결 요소 레이블링과 개수 반환

  • src: 입력 이미지, 바이너리 스케일
    labels(optional): 레이블링된 입력 이미지와 같은 크기의 배열
    connectivity(optional): 연결성을 검사할 방향 개수(4, 8 중 선택)
    ltype(optional): 결과 레이블 배열 dtype
    retval(optional): 레이블 개수
  • retval, labels, stats, centroids = cv2.connectedComponentsWithStats(src, labels, stats, centroids, connectivity, ltype): 레이블링된 각종 상태 정보 반환
    stats: N x 5 행렬 (N: 레이블 개수) [x좌표, y좌표, 폭, 높이, 너비]
    centroids: 각 레이블의 중심점 좌표, N x 2 행렬 (N: 레이블 개수)

cv2.connectedComponents() 함수를 활용해서 연결된 요소끼리 같은 색상을 칠해보겠습니다. 주석 처리된 cv2.connectedComponentsWithStats()로 코드를 돌려도 동일한 결과가 나올 겁니다.

# 연결된 영역 레이블링 (connected_label.py)

import cv2
import numpy as np

# 이미지 읽기
img = cv2.imread('../img/shapes_donut.png')
# 결과 이미지 생성
img2 = np.zeros_like(img)
# 그레이 스케일과 바이너리 스케일 변환
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, th = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)

# 연결된 요소 레이블링 적용 ---①
cnt, labels = cv2.connectedComponents(th)
#retval, labels, stats, cent = cv2.connectedComponentsWithStats(th)

# 레이블 갯수 만큼 순회
for i in range(cnt):
    # 레이블이 같은 영역에 랜덤한 색상 적용 ---②
    img2[labels==i] =  [int(j) for j in np.random.randint(0,255, 3)]

# 결과 출력
cv2.imshow('origin', img)
cv2.imshow('labeled', img2)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

연결된 부분끼리 같은 색상이 칠해진 것을 볼 수 있습니다. 위에서 말했다시피 cv2.connectedComponents() 함수는 이미지 전체에서 0으로 끊어지지 않는 부분끼리 같은 값을 부여합니다. 반환된 결과를 순회하면서 같은 값끼리는 같은 색상을 칠해주었습니다.

색 채우기

그림판 같은 그리기 도구에서 채우기 기능을 활용하여 색상을 칠해본 경험이 있을 겁니다. OpenCV의 cv2.floodFill()은 이런 기능을 제공합니다. 연속되는 영역에 같은 색상을 채워 넣는 기능을 합니다.

  • retval, img, mask, rect = cv2.floodFill(img, mask, seed, newVal, loDiff, upDiff, flags)
    img: 입력 이미지, 1 또는 3채널
    mask: 입력 이미지보다 2 x 2 픽셀이 더 큰 배열, 0이 아닌 영역을 만나면 채우기 중지
    seed: 채우기 시작할 좌표
    newVal: 채우기에 사용할 색상 값
    loDiff, upDiff(optional): 채우기 진행을 결정할 최소/최대 차이 값
    flags(optional): 채우기 방식 선택 (cv2.FLOODFILL_MASK_ONLY: img가 아닌 mask에만 채우기 적용, cv2.FLOODFILL_FIXED_RANGE: 이웃 픽셀이 아닌 seed 픽셀과 비교)
    retval: 채우기 한 픽셀의 개수
    rect: 채우기가 이루어진 영역을 감싸는 사각형

이 함수는 img 이미지의 seed 좌표에서부터 시작해서 newVal의 값으로 채우기를 시작합니다. 이때 이웃하는 픽셀에 채우기를 계속하려면 현재 픽셀이 이웃 픽셀의 loDiff를 뺀 값보다 크거나 같고 upDiff를 더한 값보다 작거나 같아야 합니다. 이것을 식으로 정리하면 아래와 같습니다. (만약 loDiff와 upDiff를 생략하면 seed의 픽셀 값과 같은 값을 갖는 이웃 픽셀만 채우기를 진행합니다.)

이웃 픽셀 - loDiff <= 현재 픽셀 <= 이웃 픽셀 + upDiff

하지만, 마지막 인자인 flags에 cv2.FLOODFILL_FIXED_RANGE가 전달되면 이웃 픽셀이 아닌 seed 픽셀과 비교하며 색을 채웁니다. 또한, flags에 cv2.FLOODFILL_MASK_ONLY가 전달되면 img에 채우기를 하지 않고 mask에만 채우기를 합니다. 

# 마우스로 색 채우기 (flood_fill.py)

import cv2
import numpy as np

img = cv2.imread('../img/taekwonv1.jpg')
rows, cols = img.shape[:2]
# 마스크 생성, 원래 이미지 보다 2픽셀 크게 ---①
mask = np.zeros((rows+2, cols+2), np.uint8)
# 채우기에 사용할 색 ---②
newVal = (255,255,255)
# 최소 최대 차이 값 ---③
loDiff, upDiff = (10,10,10), (10,10,10)

# 마우스 이벤트 처리 함수
def onMouse(event, x, y, flags, param):
    global mask, img
    if event == cv2.EVENT_LBUTTONDOWN:
        seed = (x,y)
        # 색 채우기 적용 ---④
        retval = cv2.floodFill(img, mask, seed, newVal, loDiff, upDiff)
        # 채우기 변경 결과 표시 ---⑤
        cv2.imshow('img', img)

# 화면 출력
cv2.imshow('img', img)
cv2.setMouseCallback('img', onMouse)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

마우스로 특정 영역을 클릭하면 특정 영역을 흰색으로 채우는 코드입니다. 위 코드에서 mask는 원본 이미지의 크기보다 높이와 너비를 각 2씩 크게 만들어야 합니다. 채우기에 사용할 색은 (255, 255, 255) 즉 흰색을 사용했습니다. loDiff와 upDiff, 즉 이웃한 픽셀과의 최소/최대 차이 값을 각 10으로 정했습니다.

워터셰드 (Watershed)

워터셰드(watershed)는 강물이 한 줄기로 흐르다가 갈라지는 경계인 분수령을 뜻합니다. 워터셰드는 앞서 살펴본 색 채우기(flood fill)과 비슷한 방식으로 연속된 영역을 찾는 것이라고 볼 수 있습니다. 다만, seed를 하나가 아닌 여러 개를 지정할 수 있고 이를 마커라고 합니다. 

  • markers = cv2.watershed(img, markers)
    img: 입력 이미지
    markers: 마커, 입력 이미지와 크기가 같은 1차원 배열(int32)

markers는 입력 이미지와 행과 열 크기가 같은 1차원 배열로 전달해야 합니다. markers의 값은 경계를 찾고자 하는 픽셀 영역은 -1을 갖게 하고 나머지 연결된 영역에 대해서는 동일한 정수 값을 갖게 합니다. 예를 들어 1은 배경, 2는 전경인 식입니다. cv2.watershed() 함수를 활용해 경계를 나눠보겠습니다. 

# 마우스와 워터셰드로 배경 분리 (watershed.py)

import cv2
import numpy as np

img = cv2.imread('../img/taekwonv1.jpg')
rows, cols = img.shape[:2]
img_draw = img.copy()

# 마커 생성, 모든 요소는 0으로 초기화 ---①
marker = np.zeros((rows, cols), np.int32)
markerId = 1        # 마커 아이디는 1에서 시작
colors = []         # 마커 선택한 영역 색상 저장할 공간
isDragging = False  # 드래그 여부 확인 변수

# 마우스 이벤트 처리 함수
def onMouse(event, x, y, flags, param):
    global img_draw, marker, markerId, isDragging
    if event == cv2.EVENT_LBUTTONDOWN:  # 왼쪽 마우스 버튼 다운, 드래그 시작 
        isDragging = True
        # 각 마커의 아이디와 현 위치의 색상 값을 쌍으로 매핑해서 저장 
        colors.append((markerId, img[y,x]))
    elif event == cv2.EVENT_MOUSEMOVE:  # 마우스 움직임
        if isDragging:                  # 드래그 진행 중
            # 마우스 좌표에 해당하는 마커의 좌표에 동일한 마커 아이디로 채워 넣기 ---②
            marker[y,x] = markerId
            # 마커 표시한 곳을 빨강색점으로 표시해서 출력
            cv2.circle(img_draw, (x,y), 3, (0,0,255), -1)
            cv2.imshow('watershed', img_draw)
    elif event == cv2.EVENT_LBUTTONUP:  # 왼쪽 마우스 버튼 업
        if isDragging:                  
            isDragging = False          # 드래그 중지
            # 다음 마커 선택을 위해 마커 아이디 증가 ---③
            markerId +=1
    elif event == cv2.EVENT_RBUTTONDOWN: # 오른쪽 마우스 버튼 누름
            # 모아 놓은 마커를 이용해서 워터 쉐드 적용 ---④
            cv2.watershed(img, marker)
            # 마커에 -1로 표시된 경계를 초록색으로 표시  ---⑤
            img_draw[marker == -1] = (0,255,0)
            for mid, color in colors: # 선택한 마커 아이디 갯수 만큼 반복
                # 같은 마커 아이디 값을 갖는 영역을 마커 선택한 색상으로 채우기 ---⑥
                img_draw[marker==mid] = color
            cv2.imshow('watershed', img_draw) # 표시한 결과 출력

# 화면 출력
cv2.imshow('watershed', img)
cv2.setMouseCallback('watershed', onMouse)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

이미지의 전경과 배경을 분리하는 코드입니다. 마우스를 드래그하여 로봇 태권 V 내부를 표시해주고, 또 배경도 따로 표시해줍니다. 그런 다음 오른쪽 마우스 버튼을 클릭하면 전경과 배경이 구분된 이미지를 얻을 수 있습니다.

우선은 아래 코드를 통해 0으로 채워진 마커를 생성합니다.

marker = np.zeros((rows, cols), np.int32)

그다음 아래 코드를 통해 마우스 드래그한 부분의 좌표에 해당하는 마커 좌표에 현재의 마커 아이디를 채웁니다. 이 예제에서는 전경은 1, 배경은 2로 채웁니다. 이것은 앞서 살펴본 색 채우기(flood fill)의 seed 값이 여러 개인 것 과 같은 의미입니다. 

marker[y,x] = markerId

그 다음 마우스 오른쪽 버튼을 클릭하면 아래 코드로 워터셰드를 실행합니다. 워터셰드를 실행하면 경계에 해당하는 영역은 -1로 채워지고 전경은 1, 배경은 2로 채워집니다.

cv2.watershed(img, marker)

마지막으로 -1로 채워진 마커와 같은 좌표의 이미지 픽셀은 초록색으로 바꾸고, 같은 마커 아이디 값을 갖는 영역끼리 같은 색으로 채웁니다. 이때 색은 맨 처음 마우스 왼쪽 버튼을 클릭했을 때 좌표의 픽셀 값으로 지정했습니다. 그래서 위 그림에서는 전경은 빨간색으로 채워졌고, 배경은 회색으로 채워졌습니다. 맨 처음 전경을 선택할 때 귀부분(빨간색)을 클릭했습니다.

워터셰드는 경계 검출이 어려운 경우 사용할 수 있습니다. 전경이나 배경으로 확신할 수 있는 몇몇 픽셀을 지정해줌으로써 경계를 찾을 수 있습니다. 

그랩컷 (Graph Cut)

그랩컷은 사용자가 전경(배경이 아닌 부분)으로 분리할 부분에 사각형 표시를 해주면 전경과 배경의 색상 분포를 추정해서 동일한 레이블을 가진 연결된 영역에서 전경과 배경을 분리합니다. 아래의 함수로 그랩컷을 구현할 수 있습니다.

  • mask, bgdModel, fgdModel = cv2.grabCut(img, mask, rect, bgdModel, fgdModel, iterCount, mode)
    img: 입력 이미지
    mask: 입력 이미지와 크기가 같은 1 채널 배열, 배경과 전경을 구분하는 값을 저장 (cv2.GC_BGD: 확실한 배경(0), cv2.GC_FGD: 확실한 전경(1), cv2.GC_PR_BGD: 아마도 배경(2), cv2.GC_PR_FGD: 아마도 전경(3))
    rect: 전경이 있을 것으로 추측되는 영역의 사각형 좌표, 튜플 (x1, y1, x2, y2)
    bgdModel, fgdModel: 함수 내에서 사용할 임시 배열 버퍼 (재사용할 경우 수정하지 말 것)
    iterCount: 반복 횟수
    mode(optional): 동작 방법 (cv2.GC_INIT_WITH_RECT: rect에 지정한 좌표를 기준으로 그랩컷 수행, cv2.GC_INIT_WITH_MASK: mask에 지정한 값을 기준으로 그랩컷 수행, cv2.GC_EVAL: 재시도)

mode에 cv2.GC_INIT_WITH_RECT를 전달하면 세 번째 파라미터인 rect에 전달한 사각형 좌표를 가지고 전경과 배경을 분리합니다. 그 결과를 두 번째 파라미터인 mask에 할당해 반환합니다. mask에 할당받은 값이 0과 1이면 확실한 배경, 전경을 의미하고, 2와 3이면 아마도 배경, 전경일 가능성이 있다는 뜻입니다. 이렇게 1차적으로 배경과 전경을 구분한 뒤 mode에 cv2.GC_INIT_WITH_MASK를 지정해서 다시 호출하면 좀 더 정확한 mask를 얻을 수 있습니다. 이때 bgdModel과 fgdModel은 함수가 내부적으로 연산에 사용하는 임시 배열로 다음 호출 시 이전 연산을 반영하기 위해 재사용하므로 그 내용을 수정하면 안 됩니다. 

아래는 그랩컷을 활용하여 배경을 분리하는 예제 코드입니다. 우선 마우스로 드래그하여 전경 외곽 영역을 표시해줍니다. 1차적으로 배경과 전경이 분리됩니다. 배경을 추가로 제거하고 싶으면 원본 이미지에 쉬프트 키를 누른 상태로 마우스로 검은색 선을 그어주면 됩니다. 잘못 제거된 전경을 추가하고 싶으면 원본 이미지에 컨트롤키를 누른 상태로 마우스로 흰색 선을 그어주면 됩니다.

# 마우스와 그랩컷으로 배경과 전경 분리 (grabcut.py)

import cv2
import numpy as np

img = cv2.imread('../img/taekwonv1.jpg')
img_draw = img.copy()
mask = np.zeros(img.shape[:2], dtype=np.uint8)  # 마스크 생성
rect = [0,0,0,0]    # 사각형 영역 좌표 초기화
mode = cv2.GC_EVAL  # 그랩컷 초기 모드
# 배경 및 전경 모델 버퍼
bgdmodel = np.zeros((1,65),np.float64)
fgdmodel = np.zeros((1,65),np.float64)

# 마우스 이벤트 처리 함수
def onMouse(event, x, y, flags, param):
    global mouse_mode, rect, mask, mode
    if event == cv2.EVENT_LBUTTONDOWN : # 왼쪽 마우스 누름
        if flags <= 1: # 아무 키도 안 눌렀으면
            mode = cv2.GC_INIT_WITH_RECT # 드래그 시작, 사각형 모드 ---①
            rect[:2] = x, y # 시작 좌표 저장
    # 마우스가 움직이고 왼쪽 버튼이 눌러진 상태
    elif event == cv2.EVENT_MOUSEMOVE and flags & cv2.EVENT_FLAG_LBUTTON :
        if mode == cv2.GC_INIT_WITH_RECT: # 드래그 진행 중 ---②
            img_temp = img.copy()
            # 드래그 사각형 화면에 표시
            cv2.rectangle(img_temp, (rect[0], rect[1]), (x, y), (0,255,0), 2)
            cv2.imshow('img', img_temp)
        elif flags > 1: # 키가 눌러진 상태
            mode = cv2.GC_INIT_WITH_MASK    # 마스크 모드 ---③
            if flags & cv2.EVENT_FLAG_CTRLKEY :# 컨트롤 키, 분명한 전경
                # 흰색 점 화면에 표시
                cv2.circle(img_draw,(x,y),3, (255,255,255),-1)
                # 마스크에 GC_FGD로 채우기      ---④
                cv2.circle(mask,(x,y),3, cv2.GC_FGD,-1)
            if flags & cv2.EVENT_FLAG_SHIFTKEY : # 쉬프트키, 분명한 배경
                # 검정색 점 화면에 표시
                cv2.circle(img_draw,(x,y),3, (0,0,0),-1)
                # 마스크에 GC_BGD로 채우기      ---⑤
                cv2.circle(mask,(x,y),3, cv2.GC_BGD,-1)
            cv2.imshow('img', img_draw) # 그려진 모습 화면에 출력
    elif event == cv2.EVENT_LBUTTONUP: # 마우스 왼쪽 버튼 뗀 상태 ---⑥
        if mode == cv2.GC_INIT_WITH_RECT : # 사각형 그리기 종료
            rect[2:] =x, y # 사각형 마지막 좌표 수집
            # 사각형 그려서 화면에 출력 ---⑦
            cv2.rectangle(img_draw, (rect[0], rect[1]), (x, y), (255,0,0), 2)
            cv2.imshow('img', img_draw)
        # 그랩컷 적용 ---⑧
        cv2.grabCut(img, mask, tuple(rect), bgdmodel, fgdmodel, 1, mode)
        img2 = img.copy()
        # 마스크에 확실한 배경, 아마도 배경으로 표시된 영역을 0으로 채우기
        img2[(mask==cv2.GC_BGD) | (mask==cv2.GC_PR_BGD)] = 0
        cv2.imshow('grabcut', img2) # 최종 결과 출력
        mode = cv2.GC_EVAL # 그랩컷 모드 리셋
# 초기 화면 출력 및 마우스 이벤트 등록
cv2.imshow('img', img)
cv2.setMouseCallback('img', onMouse)
while True:    
    if cv2.waitKey(0) & 0xFF == 27 : # esc
        break
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

마우스 이벤트 처리 때문에 코드가 다소 길어졌습니다. 맨 처음 마우스 드래그로 사각형을 그려주었습니다. 처음 마우스 버튼을 누른 좌표와 마지막으로 마우스 버튼을 뗀 좌표를 구해서 cv2.grabCut()을 호출할 때 mode를 cv2.GC_INIT_WITH_RECT로 설정해서 호출하면 됩니다. 그다음 쉬프트와 컨트롤키를 누른 상태로 마우스 드래그를 해주었을 때의 좌표를 mask에 반영했다가 마우스 뗀 시점에 cv2.grabCut()을 호출하여 mode에 cv2.GC_INIT_WITH_MASK를 전달하면 됩니다. 이때 쉬프트와 컨트롤키에 따라 mask에 반영할 값이 cv2.GC_BGD 또는 cv2.GC_FGD가 됩니다. 

마우스 이벤트를 처리하는 onMouse() 함수를 뜯어보겠습니다. 우선 아래 코드는 키보드의 아무 키도 누르지 않은 상태로 마우스 왼쪽 버튼을 클릭했을 때를 처리해줍니다. mode = cv2.GC_INIT_WITH_RECT으로 설정하고 시작 좌표를 구합니다.

    if event == cv2.EVENT_LBUTTONDOWN : # 왼쪽 마우스 누름
        if flags <= 1: # 아무 키도 안 눌렀으면
            mode = cv2.GC_INIT_WITH_RECT # 드래그 시작, 사각형 모드 ---①
            rect[:2] = x, y # 시작 좌표 저장

아래 코드는 마우스 왼쪽 버튼이 눌러진 상태로 드래그되었을 때를 처리해줍니다. 마우스 왼쪽 버튼이 눌러진 상태로 드래그가 되었는데 그때의 mode가 cv2.GC_INIT_WITH_RECT이라면, 단순히 마우스가 움직이는 동안 화면에 사각형을 표시합니다. mode가 cv2.GC_INIT_WITH_RECT라는 것은 키보드를 아무것도 누르지 않았다는 뜻입니다.

# 마우스가 움직이고 왼쪽 버튼이 눌러진 상태
    elif event == cv2.EVENT_MOUSEMOVE and flags & cv2.EVENT_FLAG_LBUTTON :
        if mode == cv2.GC_INIT_WITH_RECT: # 드래그 진행 중 ---②
            img_temp = img.copy()
            # 드래그 사각형 화면에 표시
            cv2.rectangle(img_temp, (rect[0], rect[1]), (x, y), (0,255,0), 2)
            cv2.imshow('img', img_temp)

반면, 마우스 왼쪽 버튼이 눌러진 상태로 드래그가 되었는데 그때의 mode가 cv2.GC_INIT_WITH_RECT이 아니고, flags가 1보다 크다면 아래의 코드가 실행됩니다. flags가 1보다 크다는 것은 키보드의 어떤 버튼이 눌렸다는 뜻입니다. 쉬프트든 컨트롤이든 눌렸다는 거죠. 이때는 mode를 cv2.GC_INIT_WITH_MASK로 설정합니다. 그리고 컨트롤/쉬프트 키가 눌렸을 때에 대해 각각 화면에 흰색 점, 검은색 점을 표시합니다. 또한 마우스가 움직인 좌표에 해당하는 mask 인덱스에 각각 cv2.GC_FGD/cv2.GC_BGD을 반영했습니다. cv2.GC_FGD는 확실한 전경, cv2.GC_BGD는 확실한 배경을 뜻합니다.

        elif flags > 1: # 키가 눌러진 상태
            mode = cv2.GC_INIT_WITH_MASK    # 마스크 모드 ---③
            if flags & cv2.EVENT_FLAG_CTRLKEY :# 컨트롤 키, 분명한 전경
                # 흰색 점 화면에 표시
                cv2.circle(img_draw,(x,y),3, (255,255,255),-1)
                # 마스크에 GC_FGD로 채우기      ---④
                cv2.circle(mask,(x,y),3, cv2.GC_FGD,-1)
            if flags & cv2.EVENT_FLAG_SHIFTKEY : # 쉬프트키, 분명한 배경
                # 검정색 점 화면에 표시
                cv2.circle(img_draw,(x,y),3, (0,0,0),-1)
                # 마스크에 GC_BGD로 채우기      ---⑤
                cv2.circle(mask,(x,y),3, cv2.GC_BGD,-1)
            cv2.imshow('img', img_draw) # 그려진 모습 화면에 출력

아래 코드는 마우스를 뗀 지점의 좌표를 구해서 사각형을 표시합니다.

    elif event == cv2.EVENT_LBUTTONUP: # 마우스 왼쪽 버튼 뗀 상태 ---⑥
        if mode == cv2.GC_INIT_WITH_RECT : # 사각형 그리기 종료
            rect[2:] =x, y # 사각형 마지막 좌표 수집
            # 사각형 그려서 화면에 출력 ---⑦
            cv2.rectangle(img_draw, (rect[0], rect[1]), (x, y), (255,0,0), 2)
            cv2.imshow('img', img_draw)

최종적으로 아래의 코드로 그랩컷을 적용합니다. mask에서 배경으로 표시된 cv2.GC_BGD(확실한 배경), cv2.GC_PR_BGD(아마도 배경)에 해당하는 좌표를 0으로 채워서 배경을 제거합니다. 그리고 최종 결과를 출력합니다.

        cv2.grabCut(img, mask, tuple(rect), bgdmodel, fgdmodel, 1, mode)
        img2 = img.copy()
        # 마스크에 확실한 배경, 아마도 배경으로 표시된 영역을 0으로 채우기
        img2[(mask==cv2.GC_BGD) | (mask==cv2.GC_PR_BGD)] = 0
        cv2.imshow('grabcut', img2) # 최종 결과 출력
        mode = cv2.GC_EVAL # 그랩컷 모드 리셋

사실 그랩컷을 적용하는 코드는 한 줄이지만, 마우스 이벤트 처리 때문에 코드가 다소 복잡해졌습니다.

평균 이동 필터

평균 이동 필터를 활용하면 물감으로 그림을 그린 것과 같이 이미지를 바꿀 수 있습니다. 평균 이동 필터를 제공하는 OpenCV 함수는 아래와 같습니다.

  • dst = cv2.pyrMeanShiftFiltering(src, sp, sr, dst, maxLevel, termcrit)
    src: 입력 이미지
    sp: 공간 윈도 반지름 크기
    sr: 색상 윈도 반지름 크기
    maxLevel(optional): 이미지 피라미드 최대 레벨
    termcrit(optional): 반복 중지 요건 (cv2.TERM_CRITERIA_EPS: 정확도가 최소 정확도(epsilon) 보다 작아지면 중지, cv2.TERM_CRITERIA_MAX_ITER: 최대 반복 횟수(max_iter)에 도달하면 중지) -> default epsilon=1, max_iter=5

이 함수는 내부적으로 이미지 피라미드를 만들어 작은 이미지의 평균 이동 결과를 큰 이미지에 적용합니다. 그래서 함수 이름 앞에 pyr가 붙었습니다. src에는 입력 이미지가 전달되는데 그레이 스케일과 컬러 스케일 모두 가능합니다. sp 파라미터는 평균 이동(MeanShift)에 사용할 윈도 크기입니다. 몇 픽셀씩 묶어서 평균을 내어 이동할지를 결정합니다. sr 파라미터는 색상 윈도 크기로 색상 값의 차이 범위를 지정합니다. 평균을 계산할 때 값의 차이가 sr 값의 범위 안에 있는 픽셀만을 대상으로 합니다. 따라서 sr이 너무 작으면 원본과 별 차이가 없고, 너무 크면 원본과 많이 달라집니다. maxLevel은 이미지 피라미드 최대 레벨입니다. 이 값이 0보다 크면 그 값만큼 작은 이미지 피라미드로 평균 이동해서 얻은 결과를 적용합니다. 값이 클수록 속도가 빨라지지만 영역과 색상이 거칠어집니다. termcrit은 반복을 중지할 기준을 지정하는 파라미터입니다. 

# 평균 이동 세그멘테이션 필터 (mean_shift.py)

import cv2
import numpy as np

img = cv2.imread('../img/taekwonv1.jpg')
# 트랙바 이벤트 처리 함수
def onChange(x):
    #sp, sr, level 선택 값 수집
    sp = cv2.getTrackbarPos('sp', 'img')
    sr = cv2.getTrackbarPos('sr', 'img')
    lv = cv2.getTrackbarPos('lv', 'img')

    # 평균 이동 필터 적용 ---①
    mean = cv2.pyrMeanShiftFiltering(img, sp, sr, None, lv)
    # 변환 이미지 출력
    cv2.imshow('img', np.hstack((img, mean)))

# 초기 화면 출력
cv2.imshow('img', np.hstack((img, img)))
# 트랙바 이벤트 함수 연결
cv2.createTrackbar('sp', 'img', 0,100, onChange)
cv2.createTrackbar('sr', 'img', 0,100, onChange)
cv2.createTrackbar('lv', 'img', 0,5, onChange)
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

이번 포스팅에서는 이미지 매칭에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/08.match_track

이미지 매칭(Image Matching)

이미지 매칭이란 서로 다른 두 이미지를 비교해서 짝이 맞는 같은 형태의 객체가 있는지 찾아내는 기술을 말합니다. 이미지에서 객체를 찾는 방법은 이미지에서 의미 있는 특징들을 적절한 숫자로 변환하고 그 숫자들을 서로 비교해서 얼마나 비슷한지 판단하는 것입니다. 쉽게 말해 두 이미지 간 유사도를 측정하는 작업입니다. 특징을 대표할 수 있는 숫자를 특징 벡터 혹은 특징 디스크립터라고 합니다. 이미지 매칭은 큰 주제이므로 이에 대해서는 앞으로 계속 포스팅할 예정입니다. 우선 이번 포스팅에서는 두 이미지에서 비슷한 그림을 찾아내는 아주 간단한 방법에 대해 알아보겠습니다.

평균 해시 매칭(Average Hash Matching)

평균 해시 매칭은 이미지 매칭의 한 기법인데, 효과는 떨어지지만 구현이 아주 간단합니다. 평균 해시 매칭은 특징 벡터를 구하기 위해 평균값을 사용합니다. 우선, 두 이미지 사이에서 비슷한 그림을 찾기 전에 찾고자 하는 그림의 특징 벡터를 구하는 방법에 대해 알아보겠습니다.

  1. 이미지를 가로 세로 비율과 무관하게 특정한 크기로 축소합니다.
  2. 픽셀 전체의 평균값을 구해서 각 픽셀의 값이 평균보다 작으면 0, 크면 1로 바꿉니다.
  3. 0 또는 1로만 구성된 각 픽셀 값을 1행 1열로 변환합니다. (이는 한 개의 2진수 숫자로 볼 수 있습니다.)

이때 비교를 하고자 하는 두 이미지를 같은 크기로 축소해야 합니다. 그렇기 때문에 0과 1의 개수도 동일합니다. (2진수로 표현했을 때 비트 개수가 같다고 볼 수 있습니다.) 2진수가 너무 길어서 보기 불편하다면 필요에 따라 10진수나 16진수 등으로 변환해서 사용할 수 있습니다.

다음은 권총 이미지를 16 x 16 크기의 평균 해시로 변환하여 특징 벡터를 구하는 코드입니다.

# 권총을 평균 해시로 변환 (avg_hash.py)

import cv2

#영상 읽어서 그레이 스케일로 변환
img = cv2.imread('../img/pistol.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# 8x8 크기로 축소 ---①
gray = cv2.resize(gray, (16,16))
# 영상의 평균값 구하기 ---②
avg = gray.mean()
# 평균값을 기준으로 0과 1로 변환 ---③
bin = 1 * (gray > avg)
print(bin)

# 2진수 문자열을 16진수 문자열로 변환 ---④
dhash = []
for row in bin.tolist():
    s = ''.join([str(i) for i in row])
    dhash.append('%02x'%(int(s,2)))
dhash = ''.join(dhash)
print(dhash)

cv2.namedWindow('pistol', cv2.WINDOW_GUI_NORMAL)
cv2.imshow('pistol', img)
cv2.waitKey(0)
OpenCV 형상 인식 - OpenCV hyeongsang insig
OpenCV 형상 인식 - OpenCV hyeongsang insig

앞서 설명드린 프로세스에 따라서 우선 이미지를 16 x 16 사이즈로 조정합니다. 그런 다음 픽셀의 전체 평균값을 구하고 그 평균값보다 큰 픽셀은 1, 작은 픽셀은 0으로 바꿉니다. 그렇게 구한 결과가 0과 1로 구성된 배열입니다. 숫자 배열을 자세히 보시면 0으로 구성된 부분이 권총 모양을 닮았음을 알 수 있습니다. 권총은 검은색이므로 픽셀 값이 0에 가깝고, 배경은 픽셀 값이 255에 가깝습니다. 따라서 전체 픽셀의 평균값보다 작은 부분은 0, 큰 부분은 1로 바뀐 것입니다. 

이제 이렇게 얻은 평균 해시를 다른 이미지의 것과 비교해서 얼마나 비슷한지를 알아내야 합니다. 비슷한 정도를 측정하는 방법에는 여러 가지가 있습니다. 그중 가장 대표적인 것이 유클리드 거리(Euclidian distance)와 해밍 거리(Hamming distance)입니다. 

유클리드 거리는 두 값의 차이로 거리를 계산합니다. 예를 들어 5와 비교할 값으로 1과 7이 있다면 5와 1의 유클리드 거리는 5-1 = 4이고, 5와 7의 유클리드 거리는 7-5 = 2입니다. 유클리드 거리가 작을수록 두 수는 비슷한 수라고 판단하므로 5는 1보다는 7과 더 유사하다고 결론짓습니다. 

해밍 거리는 두 값의 길이가 같아야 계산할 수 있습니다. 해밍 거리는 두 수의 같은 자리 값 중 서로 다른 것이 몇 개인지를 판단하여 유사도를 계산합니다. 예를 들어 12345와 비교할 값으로 12354와 92345가 있을 때 12345와 12354의 마지막 자리가 45와 54로 다르므로 해밍 거리는 2입니다. 반면 12345와 92345는 1과 9 한자리만 다르므로 해밍 거리는 1입니다. 따라서 12345는 12354보다 92345와 더 유사하다고 판단합니다. 

앞서 구한 권총의 평균 해시를 다른 이미지와 비교할 때는 해밍 거리를 써야 합니다. 유클리드 거리는 자릿수가 높을수록 차이가 크게 벌어지지만 해밍 거리는 몇 개의 숫자가 다른가만을 고려하기 때문입니다. 이미지를 비교하는데 평균 해시 숫자의 크기가 중요하기보다는 얼마나 유사한 자릿수가 많은지가 더 중요합니다. 

이제 권총 이미지의 평균 해시를 다른 이미지의 평균 해시와 해밍 거리로 비교해 유사도를 측정해보겠습니다. 우선 여러 이미지가 필요한데 아래 링크를 통해 다운로드할 수 있습니다.

https://drive.google.com/file/d/137RyRjvTBkBiIfeYBNZBtViDHQ6_Ewsp/view

다운로드한 파일을 압축 해제하여 img 디렉터리 안의 101_ObjectCategories라는 디렉터리에 저장하시기 바랍니다. 1만 장 가까운 사진들이 있는데 이 사진들과 앞서 다룬 권총 이미지를 비교하여 권총과 유사한 이미지를 찾아내는 예제입니다. 아래 코드는 권총의 평균 해시와 비교하여 해밍 거리가 25% 이내인 이미지를 출력합니다. 

# 사물 이미지 중에서 권총 이미지 찾기 (avg_hash_matching.py)

import cv2
import numpy as np
import glob

# 영상 읽기 및 표시
img = cv2.imread('../img/pistol.jpg')
cv2.imshow('query', img)

# 비교할 영상들이 있는 경로 ---①
search_dir = '../img/101_ObjectCategories'

# 이미지를 16x16 크기의 평균 해쉬로 변환 ---②
def img2hash(img):
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    gray = cv2.resize(gray, (16, 16))
    avg = gray.mean()
    bi = 1 * (gray > avg)
    return bi

# 해밍거리 측정 함수 ---③
def hamming_distance(a, b):
    a = a.reshape(1,-1)
    b = b.reshape(1,-1)
    # 같은 자리의 값이 서로 다른 것들의 합
    distance = (a !=b).sum()
    return distance

# 권총 영상의 해쉬 구하기 ---④
query_hash = img2hash(img)

# 이미지 데이타 셋 디렉토리의 모든 영상 파일 경로 ---⑤
img_path = glob.glob(search_dir+'/**/*.jpg')
for path in img_path:
    # 데이타 셋 영상 한개 읽어서 표시 ---⑥
    img = cv2.imread(path)
    cv2.imshow('searching...', img)
    cv2.waitKey(5)
    # 데이타 셋 영상 한개의 해시  ---⑦
    a_hash = img2hash(img)
    # 해밍 거리 산출 ---⑧
    dst = hamming_distance(query_hash, a_hash)
    if dst/256 < 0.25: # 해밍거리 25% 이내만 출력 ---⑨
        print(path, dst/256)
        cv2.imshow(path, img)
cv2.destroyWindow('searching...')
cv2.waitKey(0)
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

찾아낸 이미지가 대부분 권총이나 망원경과 사람의 얼굴처럼 틀린 이미지도 있습니다. 찾아낸 망원경이나 사람 얼굴을 보면 권총과 유사하게 되어 있는 부분이 있습니다. 사람의 얼굴에서는 머리 부분이 마치 권총 같아 보이기 때문에 결과로 출력된 것을 볼 수 있습니다.

템플릿 매칭 (Template Matching)

템플릿 매칭은 특정 물체에 대한 이미지를 준비해 두고 그 물체가 포함되어 있을 것이라고 예상할 수 있는 이미지와 비교하여 매칭 되는 위치를 찾는 것입니다. 이때 미리 준비한 이미지를 템플릿 이미지라고 합니다. 템플릿 이미지는 비교할 이미지보다 크기가 항상 작아야 합니다. 템플릿 매칭과 관련한 함수는 다음과 같습니다.

  • result = cv2.matchTemplate(img, templ, method, result, mask)
    img: 입력 이미지
    templ: 템플릿 이미지
    method: 매칭 메서드 (cv2.TM_SQDIFF: 제곱 차이 매칭, 완벽 매칭:0, 나쁜 매칭: 큰 값 / cv2.TM_SQDIFF_NORMED: 제곱 차이 매칭의 정규화 / cv2.TM_CCORR: 상관관계 매칭, 완벽 매칭: 큰 값, 나쁜 매칭: 0 / cv2.TM_CCORR_NORMED: 상관관계 매칭의 정규화 / cv2.TM_CCOEFF: 상관계수 매칭, 완벽 매칭:1, 나쁜 매칭: -1 / cv2.TM_CCOEFF_NORMED: 상관계수 매칭의 정규화)
    result(optional): 매칭 결과, (W - w + 1) x (H - h + 1) 크기의 2차원 배열 [여기서 W, H는 입력 이미지의 너비와 높이, w, h는 템플릿 이미지의 너비와 높이]
    mask(optional): TM_SQDIFF, TM_CCORR_NORMED인 경우 사용할 마스크
  • minVal, maxVal, minLoc, maxLoc = cv2.minMaxLoc(src, mask)
    src: 입력 1 채널 배열
    minVal, maxVal: 배열 전체에서의 최소 값, 최대 값
    minLoc, maxLoc: 최소 값과 최대 값의 좌표 (x, y)

cv2.matchTemplate() 함수는 입력 이미지(img)에서 템플릿 이미지(templ)를 슬라이딩하면서 주어진 메서드에 따라 매칭을 수행합니다. cv2.matchTemplate() 함수의 반환 값은 (W - w + 1) x (H - h + 1) 크기의 2차원 배열입니다. (여기서 W, H는 입력 이미지의 너비와 높이, w, h는 템플릿 이미지의 너비와 높이입니다.) 이 배열의 최대, 최소 값을 구하면 원하는 최선의 매칭 값과 매칭점을 구할 수 있습니다. 이것을 손쉽게 해주는 함수가 바로 cv2.minMaxLoc()입니다. 이 함수는 입력 배열에서의 최소, 최대 값뿐만 아니라 최소 값, 최대 값의 좌표도 반환합니다. 

아래는 로봇 태권 V를 다른 이미지 내에서 템플릿 매칭 방식으로 찾는 예시 코드입니다.

# 템플릿 매칭으로 객체 위치 검출 (template_matching.py)

import cv2
import numpy as np

# 입력이미지와 템플릿 이미지 읽기
img = cv2.imread('../img/figures.jpg')
template = cv2.imread('../img/taekwonv1.jpg')
th, tw = template.shape[:2]
cv2.imshow('template', template)

# 3가지 매칭 메서드 순회
methods = ['cv2.TM_CCOEFF_NORMED', 'cv2.TM_CCORR_NORMED', \
                                     'cv2.TM_SQDIFF_NORMED']
for i, method_name in enumerate(methods):
    img_draw = img.copy()
    method = eval(method_name)
    # 템플릿 매칭   ---①
    res = cv2.matchTemplate(img, template, method)
    # 최대, 최소값과 그 좌표 구하기 ---②
    min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
    print(method_name, min_val, max_val, min_loc, max_loc)

    # TM_SQDIFF의 경우 최소값이 좋은 매칭, 나머지는 그 반대 ---③
    if method in [cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED]:
        top_left = min_loc
        match_val = min_val
    else:
        top_left = max_loc
        match_val = max_val
    # 매칭 좌표 구해서 사각형 표시   ---④      
    bottom_right = (top_left[0] + tw, top_left[1] + th)
    cv2.rectangle(img_draw, top_left, bottom_right, (0,0,255),2)
    # 매칭 포인트 표시 ---⑤
    cv2.putText(img_draw, str(match_val), top_left, \
                cv2.FONT_HERSHEY_PLAIN, 2,(0,255,0), 1, cv2.LINE_AA)
    cv2.imshow(method_name, img_draw)
cv2.waitKey(0)
cv2.destroyAllWindows()    
OpenCV 형상 인식 - OpenCV hyeongsang insig

세 가지 매칭 메서드를 활용해서 템플릿 매칭을 수행했습니다. 태권 V의 위치를 잘 찾아낸 것을 볼 수 있습니다. 다만 템플릿 매칭은 크기, 방향, 회전 변환에는 잘 작동하지 않고, 속도가 느리다는 단점이 있습니다.

이번 포스팅에서는 이미지의 특징점과 특징점 검출기 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/08.match_track

이전 포스팅에서 다룬 특징 추출과 매칭 방법은 이미지 전체를 전역적으로 반영하는 방법입니다. 전역적으로 반영하기 위해서는 비교하려는 두 이미지 내 물체가 거의 비슷한 모양을 가지고 있어야 합니다. 크기가 다르다거나 회전을 했다거나 방향이 다르면 효과가 없습니다. 이런 경우 이미지를 검출하기 위해서는 이미지의 특징점을 찾아내야 합니다.

이미지 특징점

이미지 특징점이란 말 그대로 이미지에서 특징이 되는 부분을 의미합니다. 이미지끼리 서로 매칭이 되는지 확인을 할 때 각 이미지에서의 특징이 되는 부분끼리 비교를 합니다. 즉, 이미지 매칭 시 사용하는 것이 바로 특징점입니다. 특징점은 영어로 키 포인트(Keypoints)라고도 합니다. 보통 특징점이 되는 부분은 물체의 모서리나 코너입니다. 그래서 대부분의 특징점 검출을 코너 검출을 바탕으로 하고 있습니다.

해리스 코너 검출 (Harris Corner Detection)

사각형을 사각형이라고 인지할 수 있는 건 4개의 꼭짓점이 있기 때문입니다. 삼각형도 3개의 꼭짓점이 있기 때문에 삼각형이라고 인지할 수 있습니다. 마찬가지로 우리가 어떤 물체를 볼 때 꼭짓점을 더 유심히 보는 경향이 있습니다. 즉 물체를 인식할 때 물체의 코너 부분에 관심을 둡니다. 이미지 상의 코너를 잘 찾아낸다면 물체를 보다 쉽게 인식할 수 있을 것입니다. 

코너를 검출하기 위한 방법으로는 해리스 코너 검출(Harris corner detection)이 있습니다. 해리스 코너 검출은 소벨(Sobel) 미분으로 경곗값을 검출하면서 경곗값의 경사도 변화량을 측정하여 변화량이 수직, 수평, 대각선 방향으로 크게 변화하는 것을 코너로 판단합니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

OpenCV에서는 다음의 함수로 해리스 코너 검출 기능을 제공합니다.

  • dst = cv2.cornerHarris(src, blockSize, ksize, k, dst, borderType)
    src: 입력 이미지, 그레이 스케일
    blockSize: 이웃 픽셀 범위
    ksize: 소벨 미분 필터 크기
    k(optional): 코너 검출 상수 (보토 0.04~0.06)
    dst(optional): 코너 검출 결과 (src와 같은 크기의 1 채널 배열, 변화량의 값, 지역 최대 값이 코너점을 의미)
    borderType(optional): 외곽 영역 보정 형식
# 해리스 코너 검출 (corner_harris.py)

import cv2
import numpy as np

img = cv2.imread('../img/house.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# 해리스 코너 검출 ---①
corner = cv2.cornerHarris(gray, 2, 3, 0.04)
# 변화량 결과의 최대값 10% 이상의 좌표 구하기 ---②
coord = np.where(corner > 0.1* corner.max())
coord = np.stack((coord[1], coord[0]), axis=-1)

# 코너 좌표에 동그리미 그리기 ---③
for x, y in coord:
    cv2.circle(img, (x,y), 5, (0,0,255), 1, cv2.LINE_AA)

# 변화량을 영상으로 표현하기 위해서 0~255로 정규화 ---④
corner_norm = cv2.normalize(corner, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U)
# 화면에 출력
corner_norm = cv2.cvtColor(corner_norm, cv2.COLOR_GRAY2BGR)
merged = np.hstack((corner_norm, img))
cv2.imshow('Harris Corner', merged)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

결과의 오른쪽 이미지는 해리스 코너 검출을 실행하여 그 결과의 최대 값의 10% 이상인 좌표에만 빨간색 동그라미 표시한 것입니다. 빨간색 동그라미가 표시된 부분을 보면 거의 다 코너 부분임을 알 수 있습니다. 코너에서 픽셀의 변화량이 가장 크기 때문입니다. cv2.cornerHarris() 함수의 변환 결과는 입력 이미지와 크기가 같은 1차원 배열입니다. 이 결과 값의 지역 최대 값(Local Maximum)은 코너를 의미합니다. 결과의 왼쪽 이미지는 해리스 코너 검출 결과를 0~255로 정규화하여 나타낸 것입니다. 어두워 잘 안 보일 수도 있는데 오른쪽 이미지에서 빨간색 동그라미가 표시되어 있는 코너 부분이 왼쪽 이미지에서도 가장 짙게 표시된 것을 볼 수 있습니다.

시-토마시 검출 (Shi & Tomasi Detection)

해리스 코너 검출을 좀 더 개선한 알고리즘도 있습니다. 시-토마시 코너 검출 방법인데, 이를 OpenCV에서는 다음 함수로 제공합니다.

corners = cv2.goodFeaturesToTrack(img, maxCorners, qualityLevel, minDistance, corners, mask, blockSize, useHarrisDetector, k)
img: 입력 이미지
maxCorners: 얻고 싶은 코너의 개수, 강한 것 순으로
qualityLevel: 코너로 판단할 스레시홀드 값
minDistance: 코너 간 최소 거리
mask(optional): 검출에 제외할 마스크
blockSize(optional)=3: 코너 주변 영역의 크기useHarrisDetector(optional)=False: 코너 검출 방법 선택 (True: 해리스 코너 검출 방법, False: 시와 토마시 코너 검출 방법)k(optional): 해리스 코너 검출 방법에 사용할 k 계수corners: 코너 검출 좌표 결과, N x 1 x 2 크기의 배열, 실수 값이므로 정수로 변형 필요

useHarrisDetector 파라미터에 True를 전달하면 해리스 코너 검출을 하고, 디폴트 값인 False를 전달하면 시와 토마시 코너 검출을 합니다.

해리스 코너 검출에서 사용했던 동일한 이미지로 시-토마시 코너 검출을 해보겠습니다.

# 시와 토마시 코너 검출 (corner_goodFeature.py)

import cv2
import numpy as np

img = cv2.imread('../img/house.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# 시-토마스의 코너 검출 메서드
corners = cv2.goodFeaturesToTrack(gray, 80, 0.01, 10)
# 실수 좌표를 정수 좌표로 변환
corners = np.int32(corners)

# 좌표에 동그라미 표시
for corner in corners:
    x, y = corner[0]
    cv2.circle(img, (x, y), 5, (0,0,255), 1, cv2.LINE_AA)

cv2.imshow('Corners', img)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

보시는 바와 같이 해리스 코너 검출보다 더 많은 코너가 검출되었습니다. 

특징점 검출기

특징점 검출을 위한 알고리즘은 다양합니다. 또한 각각의 특징점도 좌표(x, y) 이외에 다양한 정보를 가집니다. 위에서 살펴봤던 해리스 코너 검출과 시-토마시 검출의 함수 반환 결과는 단지 특징점의 좌표였습니다. 하지만 앞으로 배울 특징점 검출기들의 반환 결과는 특징점의 좌표뿐만 아니라 다양한 정보들도 함께 반환합니다. 

OpenCV는 아래와 같은 특징점 검출 함수를 제공합니다. (detector에 각 특징점 검출기 함수를 대입하면 됩니다.)

  • keypoints = detector.detect(img, mask): 특징점 검출 함수
    img: 입력 이미지
    mask(optional): 검출 제외 마스크
    keypoints: 특징점 검출 결과 (KeyPoint의 리스트)
  • Keypoint: 특징점 정보를 담는 객체
    pt: 특징점 좌표(x, y), float 타입으로 정수 변환 필요
    size: 의미 있는 특징점 이웃의 반지름
    angle: 특징점 방향 (시계방향, -1=의미 없음)
    response: 특징점 반응 강도 (추출기에 따라 다름)
    octave: 발견된 이미지 피라미드 계층
    class_id: 특징점이 속한 객체 ID

말씀드렸다시피 detector.detect() 함수의 반환 결과인 Keypoints에는 다양한 정보들이 담겨있습니다. Keypoints는 특징점의 좌표 정보인 pt 속성을 항상 갖지만 나머지 속성은 사용하는 검출기에 따라 반환하지 않을 수도 있습니다. 검출한 특징점은 앞선 예제와 마찬가지로 cv2.circle() 함수를 사용해서 표시할 수도 있지만 OpenCV에서는 아래와 같이 특징점을 표시해주는 전용 함수를 제공합니다.

  • outImg = cv2.drawKeypoints(img, keypoints, outImg, color, flags)
    img: 입력 이미지
    keypoints: 표시할 특징점 리스트
    outImg: 특징점이 그려진 결과 이미지
    color(optional): 표시할 색상 (default: 랜덤)
    flags(optional): 표시 방법 (cv2.DRAW_MATCHES_FLAGS_DEFAULT: 좌표 중심에 동그라미만 그림(default), cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS: 동그라미의 크기를 size와 angle을 반영해서 그림)

GFTTDetector

GFTTDetector는 앞서 살펴본 cv2.goodFeaturesToTrack() 함수로 구현된 특징점 검출기입니다. GFTTDetector 함수의 생성은 아래와 같이 하고, GFTTDetector 검출기를 활용하여 특징점을 검출하는데 사용하는 함수는 바로 위에서 소개한 detect() 함수와 같습니다.

detector = cv2.GFTTDetector_create(img, maxCorners, qualityLevel, minDistance, corners, mask, blockSize, useHarrisDetector, k)모든 파라미터는 cv2.goodFeaturesToTrack()과 동일

이 검출기로 검출한 결과는 특징점 좌표(pt) 속성만 있고 나머지 속성 값은 모두 비어 있습니다.

# GFTTDetector로 특징점 검출 (kpt_gftt.py)

import cv2
import numpy as np
 
img = cv2.imread("../img/house.jpg")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# Good feature to trac 검출기 생성 ---①
gftt = cv2.GFTTDetector_create() 
# 특징점 검출 ---②
keypoints = gftt.detect(gray, None)
# 특징점 그리기 ---③
img_draw = cv2.drawKeypoints(img, keypoints, None)

# 결과 출력 ---④
cv2.imshow('GFTTDectector', img_draw)
cv2.waitKey(0)
cv2.destrolyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

GFTTDetector로 검출한 특징점을 표시했습니다. 해리스 코너 검출과 시와 토마시 코너 검출보다 더 많은 코너가 검출된 것을 볼 수 있습니다. 특징점을 표시한 동그라미의 색상이 다양합니다. 특징점을 표시하는 함수인 cv2.drawKeypoints() 함수에서 color 속성을 지정하지 않으면 랜덤한 색상으로 그려줍니다.

FAST(Feature from Accelerated Segment Test)

FAST는 기존 검출기보다 속도가 빠른 검출기입니다. FAST 검출기는 코너를 검출할 때 미분 연산을 하지 않습니다. 대신 픽셀을 중심으로 특정 개수의 픽셀로 원을 그려서 그 안의 픽셀들이 중심 픽셀 값보다 임계 값 이상 밝거나 어두운 것이 일정 개수 이상 연속되면 코너로 판단합니다. 다시 말해 어떤 점 p가 특징점인지 여부를 판단할 때, p를 중심으로 하는 원 상이 16개 픽셀 값을 봅니다. p보다 임계 값 이상 밝은 픽셀들이 n개 이상 연속되어 있거나 또는 임계 값 이상 어두운 픽셀들이 n개 이상 연속되어 있으면 p를 특징점이라고 판단합니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://wiserloner.tistory.com/879

OpenCV에서 제공하는 FAST 함수는 아래와 같이 생성합니다. 역시나 특징점 검출 시에는 detect() 함수를 사용합니다.

  • detector = cv2.FastFeatureDetector_create(threshold, nonmaxSuppression, type)
    threshold(optional): 코너 판단 임계 값 (default=10)
    nonmaxSuppression(optional): 최대 점수가 아닌 코너 억제 (default=True)
    type(optional): 엣지 검출 패턴 (cv2.FastFeatureDetector_TYPE_9_16: 16개 중 9개 연속(default), cv2.FastFeatureDetector_TYPE_7_12: 12개 중 7개 연속, cv2.FastFeatureDetector_TYPE_5_8: 8개 중 5개 연속)
# FAST로 특징점 검출 (kpt_fast.py)

import cv2
import numpy as np

img = cv2.imread('../img/house.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# FASt 특징 검출기 생성 ---①
fast = cv2.FastFeatureDetector_create(50)
# 특징점 검출 ---②
keypoints = fast.detect(gray, None)
# 특징점 그리기 ---③
img = cv2.drawKeypoints(img, keypoints, None)
# 결과 출력 ---④
cv2.imshow('FAST', img)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

SimpleBlobDetector

BLOB(Binary Large Object)는 이진 스케일로 연결된 픽셀 그룹을 말합니다. SimpleBlobDetector는 자잘한 객체는 노이즈로 여기고 특정 크기 이상의 큰 객체만 찾아내는 검출기입니다. SimpleBlobDetector는 아래와 같이 생성합니다.

  • detector = cv2.SimpleBlobDetector_create([parameters]): BLOB 검출기 생성자

parametes는 다음과 같습니다.

  • cv2.SimpleBlobDetector_Params()minThreshold, maxThreshold, thresholdStep: BLOB를 생성하기 위한 경계 값(minThreshold에서 maxThreshold를 넘지 않을 때까지 thresholdStep만큼 증가)minRepeatability: BLOB에 참여하기 위한 연속된 경계 값의 개수minDistBetweenBlobs: 두 BLOB을 하나의 BLOB으로 간주하는 거리filterByArea: 면적 필터 옵션minArea, maxArea: min~max 범위의 면적만 BLOB으로 검출filterByCircularity: 원형 비율 필터 옵션minCircularity, maxCircularity: min~max 범위의 원형 비율만 BLOB으로 검출filterByColor: 밝기를 이용한 필터 옵션blobColor: 0 = 검은색 BLOB 검출, 255 = 흰색 BLOB 검출filterByConvexity: 볼록 비율 필터 옵션minConvexity, maxConvexity: min~max 범위의 볼록 비율만 BLOB으로 검출filterByInertia: 관성 비율 필터 옵션minInertiaRatio, maxInertiaRatio: min~max 범위의 관성 비율만 BLOB으로 검출
# SimpleBolbDetector 검출기 (kpt_blob.py)

import cv2
import numpy as np
 
img = cv2.imread("../img/house.jpg")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# SimpleBlobDetector 생성 ---①
detector = cv2.SimpleBlobDetector_create()
# 키 포인트 검출 ---②
keypoints = detector.detect(gray)
# 키 포인트를 빨간색으로 표시 ---③
img = cv2.drawKeypoints(img, keypoints, None, (0,0,255),\
                flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
 
cv2.imshow("Blob", img)
cv2.waitKey(0)
OpenCV 형상 인식 - OpenCV hyeongsang insig

지금까지의 코너 검출과 다르게 창문을 몇 개 검출한 것을 볼 수 있습니다. SimpleBlobDetector는 자잘한 코너는 노이즈로 간주하고 특정 크기 이상의 객체만 검출하기 때문입니다.

동일한 SimpleBlobDetector에 필터 옵션을 주어 실행해보겠습니다.

# 필터 옵션으로 생성한 SimpleBlobDetector 검출기 (kpt_blob_param.py)

import cv2
import numpy as np
 
img = cv2.imread("../img/house.jpg")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# blob 검출 필터 파라미터 생성 ---①
params = cv2.SimpleBlobDetector_Params()

# 경계값 조정 ---②
params.minThreshold = 10
params.maxThreshold = 240
params.thresholdStep = 5
# 면적 필터 켜고 최소 값 지정 ---③
params.filterByArea = True
params.minArea = 200
  
# 컬러, 볼록 비율, 원형비율 필터 옵션 끄기 ---④
params.filterByColor = False
params.filterByConvexity = False
params.filterByInertia = False
params.filterByCircularity = False 

# 필터 파라미터로 blob 검출기 생성 ---⑤
detector = cv2.SimpleBlobDetector_create(params)
# 키 포인트 검출 ---⑥
keypoints = detector.detect(gray)
# 키 포인트 그리기 ---⑦
img_draw = cv2.drawKeypoints(img, keypoints, None, None,\
                     cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
# 결과 출력 ---⑧
cv2.imshow("Blob with Params", img_draw)
cv2.waitKey(0)
OpenCV 형상 인식 - OpenCV hyeongsang insig

몇 가지 옵션을 적용한 후 실행해보니 이전보다 더 많은 객체가 검출된 것을 볼 수 있습니다.

이번 포스팅에서는 특징 디스크립터 검출기에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/08.match_track

이전 포스팅에서 설명했다시피 특징점이란 말 그대로 이미지에서 특징이 되는 부분을 의미합니다. 이미지끼리 서로 매칭이 되는지 확인을 할 때 각 이미지에서의 특징이 되는 부분끼리 비교를 합니다. 즉, 이미지 매칭 시 사용하는 것이 바로 특징점입니다. 특징점은 영어로 키 포인트(Keypoints)라고도 합니다.

특징 디스크립터

이 특징점은 객체의 좌표뿐만 아니라 그 주변 픽셀과의 관계에 대한 정보를 가집니다. 그중 가장 대표적인 것이 size와 angle 속성이며, 코너(corner)점인 경우 코너의 경사도와 방향도 속성으로 가집니다. 특징 디스크립터(feature descriptor)란 특징점 주변 픽셀을 일정한 크기의 블록으로 나누어 각 블록에 속한 픽셀의 그레디언트 히스토그램을 계산한 것입니다. 주로 특징점 주변의 밝기, 색상, 방향, 크기 등의 정보가 포함되어 있습니다. 추출하는 알고리즘에 따라 특징 디스크립터가 일부 달라질 수는 있습니다. 일반적으로 특징점 주변의 블록 크기에 8방향(상, 하, 좌, 우 및 네 방향의 대각선) 경사도를 표현하는 경우가 많습니다. 4 x 4 크기의 블록인 경우 한 개의 특징점당 4 x 4 x 8 = 128개의 값을 갖습니다.

OpenCV는 특징 디스크립터를 추출하기 위해 다음과 같은 함수를 제공합니다.

  • keypoints, descriptors = detector.compute(image, keypoins, descriptors): 특징점을 전달하면 특징 디스크립터를 계산해서 반환
  • keypoints, descriptors = detector.detectAndCompute(image, mask, decriptors, useProvidedKeypoints): 특징점 검출과 특징 디스크립터 계산을 한 번에 수행
    image: 입력 이미지
    keypoints: 디스크립터 계산을 위해 사용할 특징점
    descriptors(optional): 계산된 디스크립터
    mask(optional): 특징점 검출에 사용할 마스크
    useProvidedKeypoints(optional): True인 경우 특징점 검출을 수행하지 않음

이전 포스팅에서 소개했던 각종 특징점 검출기를 통해 특징점을 검출한 경우에는 detector.compute() 함수를 사용해서 특징 디스크립터를 구할 수 있습니다. detector.compute() 함수의 keypoints 파라미터에 특징점을 전달해주면 됩니다. 반면 detector.detectAndCompute() 함수는 특징점과 특징 디스크립터를 동시에 계산해줍니다. 따라서 특징점 검출기로 특징점을 한번 검출하고, 그다음 detector.compute() 함수를 사용하는 것보다 처음부터 detector.detectAndCompute() 함수를 사용하는 게 더 편리하겠죠.

이제 소개할 SIFT, SURF, ORB는 모두 특징 디스크립터를 구해주는 알고리즘입니다.

SIFT (Scale-Invariant Feature Transform)

기존의 해리스 코너 검출 알고리즘은 크기 변화에 민감한 문제를 가지고 있었습니다. SIFT는 이미지 피라미드를 이용해서 크기 변화에 따른 특징점 검출 문제를 해결한 알고리즘입니다. OpenCV에서 제공하는 SIFT 객체 생성자는 다음과 같습니다.

  • detector = cv2.xfeatures2d.SIFT_create(nfeatures, nOctaveLayers, contrastThreshold, edgeThreshold, sigma)
    nfeatures: 검출 최대 특징 수
    nOctaveLayers: 이미지 피라미드에 사용할 계층 수
    contrastThreshold: 필터링할 빈약한 특징 문턱 값
    edgeThreshold: 필터링할 엣지 문턱 값
    sigma: 이미지 피라미드 0 계층에서 사용할 가우시안 필터의 시그마 값
# SIFT로 특징점 및 디스크립터 추출(desc_sift.py)

import cv2
import numpy as np

img = cv2.imread('../img/house.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# SIFT 추출기 생성
sift = cv2.xfeatures2d.SIFT_create()
# 키 포인트 검출과 서술자 계산
keypoints, descriptor = sift.detectAndCompute(gray, None)
print('keypoint:',len(keypoints), 'descriptor:', descriptor.shape)
print(descriptor)

# 키 포인트 그리기
img_draw = cv2.drawKeypoints(img, keypoints, None, \
                flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
# 결과 출력
cv2.imshow('SIFT', img_draw)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig
OpenCV 형상 인식 - OpenCV hyeongsang insig

출력된 결과를 보시면 특징점이 총 413개임을 알 수 있습니다. 그리고 특징점 1개 당 128개의 특징 디스크립터 값을 사용하는 것을 알 수 있습니다.

SURF (Speeded Up Robust Features)

SIFT는 크기 변화에 따른 특징 검출 문제를 해결하기 위해 이미지 피라미드를 사용하므로 속도가 느리다는 단점이 있습니다. SURF는 이미지 피라미드 대신 필터의 크기를 변화시키는 방식으로 성능을 개선한 알고리즘입니다. SURF는 아래와 같이 생성할 수 있습니다.

  • detector = cv2.xfeatures2d.SURF_create(hessianThreshold, nOctaves, nOctaveLayers, extended, upright)
    hessianThreshold(optional): 특징 추출 경계 값 (default=100)
    nOctaves(optional): 이미지 피라미드 계층 수 (default=3)
    extended(optional): 디스크립터 생성 플래그 (default=False), True: 128개, False: 64개
    upright(optional): 방향 계산 플래그 (default=False), True: 방향 무시, False: 방향 적용
# SURF로 특징점 및 특징 디스크립터 추출 (desc_surf.py)

import cv2
import numpy as np

img = cv2.imread('../img/house.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# SURF 추출기 생성 ( 경계:1000, 피라미드:3, 서술자확장:True, 방향적용:True)
surf = cv2.xfeatures2d.SURF_create(1000, 3, True, True)
# 키 포인트 검출 및 서술자 계산
keypoints, desc = surf.detectAndCompute(gray, None)
print(desc.shape, desc)
# 키포인트 이미지에 그리기
img_draw = cv2.drawKeypoints(img, keypoints, None, \
                flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)

cv2.imshow('SURF', img_draw)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig
OpenCV 형상 인식 - OpenCV hyeongsang insig

총 104개의 특징점과 각 특징점마다 128개의 디스크립터 값을 사용하는 것을 알 수 있습니다.

ORB (Oriented and Rotated BRIEF)

디스크립터 검출기 중 BRIEF(Binary Robust Independent Elementary Features)라는 것이 있습니다. BRIEF는 특징점 검출은 지원하지 않는 디스크립터 추출기입니다. 이 BRIEF에 방향과 회전을 고려하도록 개선한 알고리즘이 바로 ORB입니다. 이 알고리즘은 특징점 검출 알고리즘으로 FAST를 사용하고 회전과 방향을 고려하도록 개선했으며 속도도 빨라 SIFT와 SURF의 좋은 대안으로 사용됩니다. ORB 객체 생성은 다음과 같이 합니다.

  • detector = cv2.ORB_create(nfeatures, scaleFactor, nlevels, edgeThreshold, firstLevel, WTA_K, scoreType, patchSize, fastThreshold)
    nfeatures(optional): 검출할 최대 특징 수 (default=500)
    scaleFactor(optional): 이미지 피라미드 비율 (default=1.2)
    nlevels(optional): 이미지 피라미드 계층 수 (default=8)
    edgeThreshold(optional): 검색에서 제외할 테두리 크기, patchSize와 맞출 것 (default=31)
    firstLevel(optional): 최초 이미지 피라미드 계층 단계 (default=0)
    WTA_K(optional): 임의 좌표 생성 수 (default=2)
    scoreType(optional): 특징점 검출에 사용할 방식 (cv2.ORB_HARRIS_SCORE: 해리스 코너 검출(default), cv2.ORB_FAST_SCORE: FAST 코너 검출)
    patchSize(optional): 디스크립터의 패치 크기 (default=31)
    fastThreshold(optional): FAST에 사용할 임계 값 (default=20)

ORB를 활용해 특징점 및 특징 디스크립터를 검출해보겠습니다.

# ORB로 특징점 및 특징 디스크립터 검출 (desc_orb.py)

import cv2
import numpy as np

img = cv2.imread('../img/house.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# ORB 추출기 생성
orb = cv2.ORB_create()
# 키 포인트 검출과 서술자 계산
keypoints, descriptor = orb.detectAndCompute(img, None)
# 키 포인트 그리기
img_draw = cv2.drawKeypoints(img, keypoints, None, \
             flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
# 결과 출력
cv2.imshow('ORB', img_draw)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

이번 포스팅에서는 특징 매칭에 대해 알아보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/08.match_track

특징 매칭(Feature Matching)

특징 매칭이란 서로 다른 두 이미지에서 특징점과 특징 디스크립터들을 비교해서 비슷한 객체끼리 짝짓는 것을 말합니다. 

OpenCV는 특징 매칭을 위해 아래와 같은 특징 매칭 인터페이스 함수를 제공합니다.

  • matcher = cv2.DescriptorMatcher_create(matcherType): 매칭기 생성자
    matcherType: 생성할 구현 클래스의 알고리즘 ("BruteForce": NORM_L2를 사용하는 BFMatcher, "BruteForce-L1": NORM_L1을 사용하는 BFMatcher, "BruteForce-Hamming": NORM_HAMMING을 사용하는 BRMatcher, "BruteForce-Hamming(2)": NORM_HAMMING2를 사용하는 BFMatcher, "FlannBased": NORM_L2를 사용하는 FlannBasedMatcher)

OpenCV 3.4에서 제공하는 특징 매칭기는 BFMatcher와 FLannBasedMatcher가 있습니다. 객체를 생성하기 위해 해당 클래스의 생성자를 호출해도 되지만 cv2.DescriptorMatcher_create()의 파라미터로 구현할 클래스의 알고리즘을 문자열로 전달해주어도 됩니다.

cv2.DescriptorMatcher_create() 함수를 통해 생성된 특징 매칭기는 두 개의 디스크립터를 서로 비교하여 매칭 해주는 함수를 갖습니다. 3개의 함수가 있는데, match(), knnMatch(), radiusMatch()가 그것입니다. 세 함수 모두 첫 번째 파라미터인 queryDescriptors를 기준으로 두 번째 파라미터인 trainDescriptors에 맞는 매칭을 찾습니다. 

  • matches: matcher.match(queryDescriptors, trainDescriptors, mask): 1개의 최적 매칭queryDescriptors: 특징 디스크립터 배열, 매칭의 기준이 될 디스크립터trainDescriptors: 특징 디스크립터 배열, 매칭의 대상이 될 디스크립터mask(optional): 매칭 진행 여부 마스크matches: 매칭 결과, DMatch 객체의 리스트
  • matches = matcher.knnMatch(queryDescriptors, trainDescriptors, k, mask, compactResult): k개의 가장 근접한 매칭k: 매칭할 근접 이웃 개수compactResult(optional): True: 매칭이 없는 경우 매칭 결과에 불포함 (default=False)
  • matches = matcher.radiusMatch(queryDescriptors, trainDescriptors, maxDistance, mask, compactResult): maxDistance 이내의 거리 매칭maxDistance: 매칭 대상 거리

match() 함수는 queryDescriptors 한 개당 최적의 매칭을 이루는 trainDescriptors를 찾아 결과로 반환합니다. 그러나 최적 매칭을 찾지 못하는 경우도 있기 때문에 반환되는 매칭 결과 개수가queryDescriptors의 개수보다 적을 수도 있습니다.

knnMatch() 함수는 queryDescriptors 한 개당 k개의 최근접 이웃 개수만큼 trainDescriptors에서 찾아 반환합니다. k는 세 번째 파라미터입니다. k개의 최근접 이웃 개수만큼이라는 말은 가장 비슷한 k개만큼의 매칭 값을 반환한다는 뜻입니다. CompactResult에 default값이 False가 전달되면 매칭 결과를 찾지 못해도 결과에 queryDescriptors의 ID를 보관하는 행을 추가합니다. True가 전달되면 아무것도 추가하지 않습니다.

radiusMatch() 함수는 queryDescriptors에서 maxDistance 이내에 있는 trainDescriptors를 찾아 반환합니다. 

위 세 함수인 match(), knnMatch(), radiusMatch() 함수의 반환 결과는 DMatch 객체 리스트입니다.

  • DMatch: 매칭 결과를 표현하는 객체 
    queryIdx: queryDescriptors의 인덱스
    trainIdx: trainDescriptors의 인덱스
    imgIdx: trainDescriptor의 이미지 인덱스
    distance: 유사도 거리

DMatch 객체의 queryIdx와 trainIdx로 두 이미지의 어느 지점이 서로 매칭 되었는지 알 수 있습니다. 또한 distnace로 얼마나 가까운 거리 인지도 알 수 있습니다.

매칭 결과를 시각적으로 표현하기 위해 두 이미지를 하나로 합쳐서 매칭점끼리 선으로 연결하는 작업이 필요한데, 이를 위해 OpenCV에서는 아래의 함수를 제공합니다.

  • cv2.drawMatches(img1, kp1, img2, kp2, matches, flags): 매칭점을 이미지에 표시
    img1, kp1: queryDescriptor의 이미지와 특징점
    img2, kp2: trainDescriptor의 이미지와 특징점
    matches: 매칭 결과
    flags: 매칭점 그리기 옵션 (cv2.DRAW_MATCHES_FLAGS_DEFAULT: 결과 이미지 새로 생성(default값), cv2.DRAW_MATCHES_FLAGS_DRAW_OVER_OUTIMG: 결과 이미지 새로 생성 안 함, cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS: 특징점 크기와 방향도 그리기, cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS: 한쪽만 있는 매칭 결과 그리기 제외)

BFMatcher(Brute-Force Matcher)

Brute-Force 매칭기는 queryDescriptors와 trainDescriptors를 하나하나 확인해 매칭되는지 판단하는 알고리즘으로 OpenCV에서는 cv2.BFMatcher 클래스로 제공합니다. Brute-Force 매칭기는 아래와 같이 생성합니다.

  • matcher = cv2.BFMatcher_create(normType, crossCheck)
    normType: 거리 측정 알고리즘 (cv2.NORM_L1, cv2.NORM_L2(default), cv2.NORM_L2SQR, cv2.NORM_HAMMING, cv2.NORM_HAMMING2)
    crosscheck: 상호 매칭이 되는 것만 반영 (default=False)

거리 측정 알고리즘을 전달하는 파라미터인 normType의 값은 다음과 같이 계산합니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

세 가지 유클리드 거리 측정법과 두 가지 해밍 거리 측정법 중에 선택을 할 수 있습니다. SIFT와 SURF 디스크립터 검출기의 경우 NORM_L1, NORM_L2가 적합하고 ORB로 디스크립터 검출기의 경우 NORM_HAMMING이 적합하며, NORM_HAMMING2는 ORB의 WTA_K가 3 혹은 4일 때 적합하다고 합니다. crosscheck가 True이면 양쪽 디스크립터 모두에게서 매칭이 완성된 것만 반영하므로 불필요한 매칭을 줄일 수 있지만 그만큼 속도가 느려진다는 단점이 있습니다.

아래는 SIFT 디스크립터 검출기로 검출한 두 이미지의 특징점 및 디스크립터를 사용하여 BFMatcher로 매칭되는 부분을 찾는 예제 코드입니다.

# BFMatcher와 SIFT로 매칭 (match_bf_sift.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# SIFT 서술자 추출기 생성 ---①
detector = cv2.xfeatures2d.SIFT_create()
# 각 영상에 대해 키 포인트와 서술자 추출 ---②
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)

# BFMatcher 생성, L1 거리, 상호 체크 ---③
matcher = cv2.BFMatcher(cv2.NORM_L1, crossCheck=True)
# 매칭 계산 ---④
matches = matcher.match(desc1, desc2)
# 매칭 결과 그리기 ---⑤
res = cv2.drawMatches(img1, kp1, img2, kp2, matches, None, \
                      flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
# 결과 출력 
cv2.imshow('BFMatcher + SIFT', res)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

우선 SIFT 디스크립터 검출기를 생성합니다. 그런 다음 각 이미지에 대해 특징점과 디스크립터를 추출합니다. BFMatcher 객체를 생성한 뒤 이를 활용하여 두 이미지의 디스크립터로 매칭 계산을 합니다. 매칭 결과를 화면에 표시까지 해주었습니다.

다음은 SURF 디스크립터 검출기로 얻은 디스크립터를 BFMatcher로 매칭하는 코드입니다.

# BFMatcher와 SURF로 매칭 (match_bf_surf.py)

import cv2
import numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# SURF 서술자 추출기 생성 ---①
detector = cv2.xfeatures2d.SURF_create()
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)

# BFMatcher 생성, L2 거리, 상호 체크 ---③
matcher = cv2.BFMatcher(cv2.NORM_L2, crossCheck=True)
# 매칭 계산 ---④
matches = matcher.match(desc1, desc2)
# 매칭 결과 그리기 ---⑤
res = cv2.drawMatches(img1, kp1, img2, kp2, matches, None, \
                     flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)

cv2.imshow('BF + SURF', res)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

매칭되는 부분이 더 많아진 걸 볼 수 있습니다. 다음으로 ORB를 통한 매칭코드를 살펴보겠습니다.

# BFMatcher와 ORB로 매칭 (match_bf_orb.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# SIFT 서술자 추출기 생성 ---①
detector = cv2.ORB_create()
# 각 영상에 대해 키 포인트와 서술자 추출 ---②
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)

# BFMatcher 생성, Hamming 거리, 상호 체크 ---③
matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
# 매칭 계산 ---④
matches = matcher.match(desc1, desc2)
# 매칭 결과 그리기 ---⑤
res = cv2.drawMatches(img1, kp1, img2, kp2, matches, None, \
                     flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)

cv2.imshow('BFMatcher + ORB', res)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

FLANN(Fast Library for Approximate Nearest Neighbors Matching)

BFMatcher는 모든 디스크립터를 전수 조사하므로 이미지 사이즈가 클 경우 속도가 굉장히 느립니다. 이를 해결하기 위해 FLANN을 사용할 수 있습니다. FLANN은 모든 디스크립터를 전수 조사하기 보다 이웃하는 디스크립터끼리 비교를 합니다. 이웃하는 디스크립터를 찾기 위해 FLANN 알고리즘 함수에 인덱스 파라미터와 검색 파라미터를 전달해야 합니다.

OpenCV는 FLANN 객체 생성을 위한 함수로 cv2.FlannBasedMatcher()를 제공합니다. 이 함수는 인덱스 파라미터로 indexParams를 전달받고 검색 파라미터로 searchParams를 전달받습니다. 두 파라미터 모두 딕셔너리 형태입니다.

matcher = cv2.FlannBasedMatcher(indexParams, searchParams)

파라미터로 전달 받는 인덱스 파라미터와 검색 파라미터는 다음과 같은 값을 갖습니다.

indexParams: 인덱스 파라미터 (딕셔너리)

  • algorithm: 알고리즘 선택 키, 선택할 알고리즘에 따라 종속 키를 결정하면 됨
    FLANN_INDEX_LINEAR=0: 선형 인덱싱, BFMatcher와 동일
    FLANN_INDEX_KDTREE=1: KD-트리 인덱싱 (trees=4: 트리 개수(16을 권장))
    FLANN_INDEX_KMEANS=2: K-평균 트리 인덱싱 (branching=32: 트리 분기 개수, iterations=11: 반복 횟수, centers_init=0: 초기 중심점 방식)
    FLANN_INDEX_COMPOSITE=3: KD-트리, K-평균 혼합 인덱싱 (trees=4: 트리 개수, branching=32: 트리 분기 새수, iterations=11: 반복 횟수, centers_init=0: 초기 중심점 방식)
    FLANN_INDEX_LSH=6: LSH 인덱싱 (table_number: 해시 테이블 수, key_size: 키 비트 크기, multi_probe_level: 인접 버킷 검색)
    FLANN_INDEX_AUTOTUNED=255: 자동 인덱스 (target_precision=0.9: 검색 백분율, build_weight=0.01: 속도 우선순위, memory_weight=0.0: 메모리 우선순위, sample_fraction=0.1: 샘플 비율)

searchParams: 검색 파라미터 (딕셔너리)

  • searchParams: 검색 파라미터 (딕셔너리)checks=32: 검색할 후보 수eps=0.0: 사용 안 함sorted=True: 정렬해서 반환

인덱스 파라미터는 결정해야 할 값이 너무 많아 복잡합니다. 그래서 아래와 같이 설정하는 것을 권장합니다.

<sift나 surf를="" 사용하는="" 경우=""></sift나>

FLANN_INDEDX_KDTREE = 1
index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)

<orb를 사용하는="" 경우=""></orb를>

FLANN_INDEX_LSH = 6
index_params = dict(algorithm=FLANN_INDEX_LSH, table_number=6, key_size=12, multi_probe_level=1)

아래 코드는 SIFT 검출기로 추출한 디스크립터를 cv2.FlannBasedMatcher로 매칭하는 예제입니다.

# FLANNMatcher와 SIFT로 매칭 (match_flann_sift.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# SIFT 생성
detector = cv2.xfeatures2d.SIFT_create()
# 키 포인트와 서술자 추출
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)

# 인덱스 파라미터와 검색 파라미터 설정 ---①
FLANN_INDEX_KDTREE = 1
index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
search_params = dict(checks=50)

# Flann 매처 생성 ---③
matcher = cv2.FlannBasedMatcher(index_params, search_params)
# 매칭 계산 ---④
matches = matcher.match(desc1, desc2)
# 매칭 그리기
res = cv2.drawMatches(img1, kp1, img2, kp2, matches, None, \
                flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)

cv2.imshow('Flann + SIFT', res)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

아래 코드는 SURF 검출기로 추출한 디스크립터를 cv2.FlannBasedMatcher로 매칭하는 예제입니다.

# FLANNMatcher와 SURF로 매칭 (match_flann_surf.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# SURF 생성
detector = cv2.xfeatures2d.SURF_create()
# 키 포인트와 서술자 추출
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)

# 인덱스 파라미터와 검색 파라미터 설정 ---①
FLANN_INDEX_KDTREE = 1
index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
search_params = dict(checks=50)

# Flann 매처 생성 ---③
matcher = cv2.FlannBasedMatcher(index_params, search_params)
# 매칭 계산 ---④
matches = matcher.match(desc1, desc2)
# 매칭 그리기
res = cv2.drawMatches(img1, kp1, img2, kp2, matches, None, \
                flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)

cv2.imshow('Flann + SURF', res)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

아래 코드는 ORB 검출기로 추출한 디스크립터를 cv2.FlannBasedMatcher로 매칭하는 예제입니다.

# FLANNMatcher와 ORB로 매칭 (match_flann_orb.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# ORB 추출기 생성
detector = cv2.ORB_create()
# 키 포인트와 서술자 추출
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)

# 인덱스 파라미터 설정 ---①
FLANN_INDEX_LSH = 6
index_params= dict(algorithm = FLANN_INDEX_LSH,
                   table_number = 6,
                   key_size = 12,
                   multi_probe_level = 1)
# 검색 파라미터 설정 ---②
search_params=dict(checks=32)
# Flann 매처 생성 ---③
matcher = cv2.FlannBasedMatcher(index_params, search_params)
# 매칭 계산 ---④
matches = matcher.match(desc1, desc2)
# 매칭 그리기
res = cv2.drawMatches(img1, kp1, img2, kp2, matches, None, \
            flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
# 결과 출력            
cv2.imshow('Flann + ORB', res)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

지금까지 살펴본 매칭 결과에는 잘못된 매칭 정보가 너무 많습니다. 가령 바로 위 그림에서 로보트 태권V와 흰 가운을 입고 있는 인형과 매칭이 되는 부분도 있습니다. 이는 분명 잘못 매칭된 결과입니다. 따라서 잘못된 매칭 결과를 제거하여 올바른 매칭점을 찾아내는 작업이 추가로 필요합니다. 이에 대해서는 다음 포스팅에서 살펴보겠습니다.

이번 포스팅은 이전 포스팅의 후속 편입니다. 이전 포스팅에서는 특징 매칭에 대해 알아봤습니다. 그러나 잘못된 특징 매칭이 너무 많았습니다. 잘못된 특징 매칭은 제외하고 올바른 매칭점을 찾는 작업이 추가로 필요합니다. 이번 포스팅에서는 올바른 매칭점을 찾는 방법에 대해 배워보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/08.match_track

올바른 매칭점 찾기

이전 포스팅에서 match(), knnMatch(), radiusMatch() 함수를 활용하여 매칭점을 찾는 실습을 했습니다. 그러나 잘못된 매칭 결과가 굉장히 많이 포함되어 있었습니다. 매칭 결과에서 쓸모없는 매칭점은 버리고 올바른 매칭점만 골라내는 작업이 필요합니다. 만약 올바른 매칭점만 남겼는데 매칭점이 몇 개 없다면 두 이미지는 서로 연관관계가 없다고 판단해야 합니다.

radiusMatch()는 maxDistance 파라미터를 조절하는 것 말고는 큰 의미가 없으므로 제외하고, match()와 knnMatch() 함수를 활용해 올바른 매칭점을 찾는 방법에 대해 알아보겠습니다.

match() 함수는 모든 디스크립터를 하나하나 비교하여 매칭점을 찾습니다. 따라서 가장 작은 거리 값과 큰 거리 값의 상위 몇 퍼센트만 골라서 올바른 매칭점을 찾을 수 있습니다.

아래는 match() 함수를 통해 올바른 매칭점을 찾는 코드입니다. ORB로 디스크립터를 추출하고 BF-Hamming 매칭기로 매칭을 계산했습니다. 매칭 결과의 거리(distance)를 기준으로 정렬하고 거리가 짧은 20%의 매칭점만 골랐습니다.

# match 함수로부터 올바른 매칭점 찾기 (match_good.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# ORB로 서술자 추출 ---①
detector = cv2.ORB_create()
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)
# BF-Hamming으로 매칭 ---②
matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = matcher.match(desc1, desc2)

# 매칭 결과를 거리기준 오름차순으로 정렬 ---③
matches = sorted(matches, key=lambda x:x.distance)
# 최소 거리 값과 최대 거리 값 확보 ---④
min_dist, max_dist = matches[0].distance, matches[-1].distance
# 최소 거리의 15% 지점을 임계점으로 설정 ---⑤
ratio = 0.2
good_thresh = (max_dist - min_dist) * ratio + min_dist
# 임계점 보다 작은 매칭점만 좋은 매칭점으로 분류 ---⑥
good_matches = [m for m in matches if m.distance < good_thresh]
print('matches:%d/%d, min:%.2f, max:%.2f, thresh:%.2f' \
        %(len(good_matches),len(matches), min_dist, max_dist, good_thresh))
# 좋은 매칭점만 그리기 ---⑦
res = cv2.drawMatches(img1, kp1, img2, kp2, good_matches, None, \
                flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
# 결과 출력
cv2.imshow('Good Match', res)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

대부분 매칭이 잘 되었습니다.

이제는 knnMatch() 함수로 올바른 매칭점을 찾아보겠습니다. knnMatch() 함수는 디스크립터당 k개의 최근접 이웃 매칭점을 가까운 순서대로 반환합니다. k개의 최근접 이웃 중 거리가 가까운 것은 좋은 매칭점이고, 거리가 먼 것은 좋지 않은 매칭점일 가능성이 높습니다. 최근접 이웃 중 거리가 가까운 것 위주로 골라내면 좋은 매칭점을 찾아낼 수 있습니다.

# knnMatch 함수로부터 올바른 매칭점 찾기 (match_good_knn.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# ORB로 서술자 추출 ---①
detector = cv2.ORB_create()
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)
# BF-Hamming 생성 ---②
matcher = cv2.BFMatcher(cv2.NORM_HAMMING2)
# knnMatch, k=2 ---③
matches = matcher.knnMatch(desc1, desc2, 2)

# 첫번재 이웃의 거리가 두 번째 이웃 거리의 75% 이내인 것만 추출---⑤
ratio = 0.75
good_matches = [first for first,second in matches \
                    if first.distance < second.distance * ratio]
print('matches:%d/%d' %(len(good_matches),len(matches)))

# 좋은 매칭만 그리기
res = cv2.drawMatches(img1, kp1, img2, kp2, good_matches, None, \
                    flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
# 결과 출력                    
cv2.imshow('Matching', res)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

knnMatch() 함수에서 k는 2로 설정했습니다. 최근접 이웃 중 거리가 가까운 것 중 75%만 골랐습니다. 반환된 결과를 보면 모두 잘 매칭이 되었습니다.

매칭 영역 원근 변환

올바르게 매칭된 좌표들에 원근 변환 행렬을 구하면 매칭 되는 물체가 어디 있는지 표시할 수 있습니다. 위에서 실습했던 이미지를 보겠습니다. 찾고자 하는 물체는 로봇 태권 V입니다. 두 사진에서 로봇 태권 V의 크기, 방향, 색상 등이 거의 동일합니다만 조금 다릅니다. 비교하려는 물체가 두 사진 상에서 약간 회전했을 수도 있고 크기가 조금 다를 수도 있습니다. 이에 대해 원근 변환 행렬을 구하면 찾고자 하는 물체의 위치를 잘 찾을 수 있습니다. 더불어 원근 변환 행렬에 들어맞지 않는 매칭점을 구분할 수 있어서 나쁜 매칭점을 한번 더 제거할 수 있습니다.

여러 매칭점으로 원근 변환 행렬을 구하는 함수는 cv2.findHomography()이고, 원래 좌표들을 원근 변환 행렬로 변환하는 함수는 cv2.perspectiveTransform()입니다.

  • mtrx, mask = cv2.findHomography(srcPoints, dstPoints, method, ransacReprojThreshold, mask, maxIters, confidence)
    srcPoints: 원본 좌표 배열
    dstPoints: 결과 좌표 배열
    method=0(optional): 근사 계산 알고리즘 선택 (0: 모든 점으로 최소 제곱 오차 계산, cv2.RANSAC, cv2.LMEDS, cv2.RHO)
    ransacReprojThreshold=3(optional): 정상치 거리 임계 값(RANSAC, RHO인 경우)
    maxIters=2000(optional): 근사 계산 반복 횟수
    confidence=0.995(optional): 신뢰도(0~1의 값)
    mtrx: 결과 변환 행렬
    mask: 정상치 판별 결과, N x 1 배열 (0: 비정상치, 1: 정상치)
  • dst = cv2.perspectiveTransform(src, m, dst)
    src: 입력 좌표 배열
    m: 변환 배열
    dst(optional): 출력 좌표 배열

cv2.findHomography() 함수는 cv2.getPerspectiveTransform() 함수와 비슷합니다. 다만 cv2.getPerspectiveTransform()은 4개의 꼭짓점으로 정확한 원근 변환 행렬을 반환하지만, cv2.findHomography()는 여러 개의 점으로 근사 계산한 원근 변환 행렬을 반환합니다. cv2.perspectiveTransform() 함수는 원근 변환할 새로운 좌표 배열을 반환합니다.

cv2.findHomography() 함수의 method 파라미터에는 0, cv2.RANSAC, cv2.LMEDS, cv2.RHO의 값을 전달할 수 있습니다. 이는 원근 변환의 근사 계산을 위한 알고리즘입니다. default 값인 0은 모든 좌표를 최소 제곱 법으로 근사 계산합니다. 이는 모든 좌표에 대해 계산되므로 틀린 매칭점이 있다면 오차가 클 수 있습니다.

method 파라미터에 cv2.RANSAC를 전달하면 RANSAC(Random Sample Consensus) 알고리즘을 사용합니다. 이는 모든 좌표를 사용하지 않고 임의의 좌표만 선정해서 만족도를 구하는 방식인데, 이렇게 구한 만족도가 큰 것만 선정하여 근사 계산합니다. 선정된 점들은 정상치로 분류하고 그 외의 점들은 이상치로 분류해서 노이즈로 판단합니다. 이때 이상치를 구분하는 임계 값으로 ransacReprojThreshold 값을 정하면 됩니다. 변환 값은 결과 변환 행렬(mtrx)과 정상치 판별 결과(mask)입니다. mask에는 입력 좌표와 동일한 인덱스에 정상치는 1, 이상치는 0으로 표시됩니다. 이는 올바른 매칭점과 나쁜 매칭점을 구분하는데 활용할 수 있습니다. 특징 매칭을 하더라도 올바르지 않은 매칭점들이 굉장히 많다는 걸 이전 포스팅에서 살펴봤습니다. 하지만 cv2.RANSAC을 활용한다면 정상치와 이상치를 구분해주는 mask를 반환하므로 올바른 매칭점과 나쁜 매칭점을 한번 더 구분할 수 있습니다.

method 파라미터에 cv2.LMEDS를 전달하면 LMedS(Least Median of Squares) 알고리즘을 활용하여 제곱의 최소 중간값을 사용합니다. 이 알고리즘은 추가적인 파라미터를 요구하지 않아 사용하기에는 편하지만, 정상치가 50% 이상인 경우에만 정상적으로 작동하니 주의할 필요가 있습니다.

cv2.RHO는 RANSAC을 개선한 PROSAC(Progressive Sample Consensus) 알고리즘을 사용합니다. 아 알고리즘은 이상치가 많은 경우에 더 빠릅니다.

아래 코드는 올바른 매칭점을 활용해 원근 변환 행렬을 구하고, 원본 이미지 크기만큼의 사각형 도형을 원근 변환하여 결과 이미지에 표시하는 코드입니다. 이렇게 함으로써 찾고자 하는 물체가 어디 있는지 표시할 수 있습니다.

# 매칭점 원근 변환으로 영역 찾기 (match_homography.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# ORB, BF-Hamming 로 knnMatch  ---①
detector = cv2.ORB_create()
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)
matcher = cv2.BFMatcher(cv2.NORM_HAMMING2)
matches = matcher.knnMatch(desc1, desc2, 2)

# 이웃 거리의 75%로 좋은 매칭점 추출---②
ratio = 0.75
good_matches = [first for first,second in matches \
                    if first.distance < second.distance * ratio]
print('good matches:%d/%d' %(len(good_matches),len(matches)))

# 좋은 매칭점의 queryIdx로 원본 영상의 좌표 구하기 ---③
src_pts = np.float32([ kp1[m.queryIdx].pt for m in good_matches ])
# 좋은 매칭점의 trainIdx로 대상 영상의 좌표 구하기 ---④
dst_pts = np.float32([ kp2[m.trainIdx].pt for m in good_matches ])
# 원근 변환 행렬 구하기 ---⑤
mtrx, mask = cv2.findHomography(src_pts, dst_pts)
# 원본 영상 크기로 변환 영역 좌표 생성 ---⑥
h,w, = img1.shape[:2]
pts = np.float32([ [[0,0]],[[0,h-1]],[[w-1,h-1]],[[w-1,0]] ])
# 원본 영상 좌표를 원근 변환  ---⑦
dst = cv2.perspectiveTransform(pts,mtrx)
# 변환 좌표 영역을 대상 영상에 그리기 ---⑧
img2 = cv2.polylines(img2,[np.int32(dst)],True,255,3, cv2.LINE_AA)

# 좋은 매칭 그려서 출력 ---⑨
res = cv2.drawMatches(img1, kp1, img2, kp2, good_matches, None, \
                    flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
cv2.imshow('Matching Homography', res)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

결과 이미지에서 찾고자 하는 물체(여기서는 로봇 태권 v)가 어디 있는지 표시되었습니다.

print('good matches:%d/%d' %(len(good_matches),len(matches)))

이 부분까지는 '올바른 매칭점 찾기'에서 활용한 코드와 동일합니다. 

원근 변환 행렬을 구하는 코드는 아래 부분입니다.

# 좋은 매칭점의 queryIdx로 원본 영상의 좌표 구하기 ---③
src_pts = np.float32([ kp1[m.queryIdx].pt for m in good_matches ])
# 좋은 매칭점의 trainIdx로 대상 영상의 좌표 구하기 ---④
dst_pts = np.float32([ kp2[m.trainIdx].pt for m in good_matches ])
# 원근 변환 행렬 구하기 ---⑤
mtrx, mask = cv2.findHomography(src_pts, dst_pts)

우선 good_matches는 knnMatch() 함수의 반환 결과입니다.

이전 포스팅에서 살펴본 바와 같이 match(), knnMatch(), radiusMatch() 함수의 반환 결과는 DMatch 객체 리스트입니다.

  • DMatch: 매칭 결과를 표현하는 객체 
    queryIdx: queryDescriptors의 인덱스
    trainIdx: trainDescriptors의 인덱스
    imgIdx: trainDescriptor의 이미지 인덱스
    distance: 유사도 거리

따라서 good_matches 배열에서 하나의 원소 m에 대한 m.queryIdx 값은 입력 이미지의 디스크립터(queryDescriptors)에 해당하는 인덱스입니다. 그리하여 kp1[m.queryIdx].pt는 ORB로 추출한 모든 특징점들 중 m.queryIdx에 해당하는 특징점 좌표들만 선택한다는 뜻입니다. 이것이 바로 올바른 매칭점입니다. 입력 이미지에 대한 올바른 매칭점 src_pts와 대상 이미지에 대한 올바른 매칭점 dst_pts를 구했습니다. 올바른 매칭점인 src_pts, dst_pts를 활용하여 cv2.findHomography() 함수로 원근 변환 행렬을 구할 수 있습니다. 

아래의 코드를 활용하여 원근 변환한 도형을 대상 이미지에 표시했습니다.

h,w, = img1.shape[:2]
pts = np.float32([ [[0,0]],[[0,h-1]],[[w-1,h-1]],[[w-1,0]] ])
# 원본 영상 좌표를 원근 변환  ---⑦
dst = cv2.perspectiveTransform(pts,mtrx)
# 변환 좌표 영역을 대상 영상에 그리기 ---⑧
img2 = cv2.polylines(img2,[np.int32(dst)],True,255,3, cv2.LINE_AA)

먼저, 원본 이미지의 크기에 대한 사각형 좌표를 구합니다. 그다음 이 사각형 좌표에 대해 원근 변환 행렬(mtrx)을 합니다. 이렇게 구한 원근 변환된 좌표를 대상 이미지에 표시했습니다. 이렇게 해서 찾는 물체가 어디 있는지 나타낼 수 있습니다. 사실 본 예제에는 잘못된 매칭점이 포함되어 있지 않았습니다. 하지만 대부분의 경우 올바른 매칭점을 골라내도 그 속에는 잘못된 매칭점이 섞여 있습니다. 이 경우 결과 값 mask를 활용하여 정상치와 이상치를 구별해주어 잘못된 매칭점을 추가로 제거해주어야 합니다. 

다음은 RANSAC 원근 변환 근사 계산으로 잘못된 매칭을 추가로 제거하는 코드입니다.

# RANSAC 원근 변환 근사 계산으로 나쁜 매칭 제거 (match_homography_accuracy.py)

import cv2, numpy as np

img1 = cv2.imread('../img/taekwonv1.jpg')
img2 = cv2.imread('../img/figures2.jpg')
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)

# ORB, BF-Hamming 로 knnMatch  ---①
detector = cv2.ORB_create()
kp1, desc1 = detector.detectAndCompute(gray1, None)
kp2, desc2 = detector.detectAndCompute(gray2, None)
matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = matcher.match(desc1, desc2)

# 매칭 결과를 거리기준 오름차순으로 정렬 ---③
matches = sorted(matches, key=lambda x:x.distance)
# 모든 매칭점 그리기 ---④
res1 = cv2.drawMatches(img1, kp1, img2, kp2, matches, None, \
                    flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)

# 매칭점으로 원근 변환 및 영역 표시 ---⑤
src_pts = np.float32([ kp1[m.queryIdx].pt for m in matches ])
dst_pts = np.float32([ kp2[m.trainIdx].pt for m in matches ])
# RANSAC으로 변환 행렬 근사 계산 ---⑥
mtrx, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
h,w = img1.shape[:2]
pts = np.float32([ [[0,0]],[[0,h-1]],[[w-1,h-1]],[[w-1,0]] ])
dst = cv2.perspectiveTransform(pts,mtrx)
img2 = cv2.polylines(img2,[np.int32(dst)],True,255,3, cv2.LINE_AA)

# 정상치 매칭만 그리기 ---⑦
matchesMask = mask.ravel().tolist()
res2 = cv2.drawMatches(img1, kp1, img2, kp2, matches, None, \
                    matchesMask = matchesMask,
                    flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
# 모든 매칭점과 정상치 비율 ---⑧
accuracy=float(mask.sum()) / mask.size
print("accuracy: %d/%d(%.2f%%)"% (mask.sum(), mask.size, accuracy))

# 결과 출력                    
cv2.imshow('Matching-All', res1)
cv2.imshow('Matching-Inlier ', res2)
cv2.waitKey()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig
OpenCV 형상 인식 - OpenCV hyeongsang insig

원근 변환 행렬을 구할 때 RANSAC을 사용했고, 그 결과인 mask를 활용하여 잘못된 매칭점을 제거했습니다. mask에는 입력 좌표와 동일한 인덱스에 정상치에는 1, 이상치에는 0이 표시된다고 했습니다. 이는 올바른 매칭점과 나쁜 매칭점을 구분하는데 활용할 수 있습니다. 

이번 포스팅에서는 객체 추적 방법인 배경 제거에 대해 배워보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/08.match_track

객체 추적(Object Tracking)

동영상에서 지속적으로 움직이는 객체를 찾는 방법을 객체 추적이라고 합니다. 객체 추적 방법은 여러 가지가 있습니다. 몇 가지만 알아보겠습니다. 이번 포스팅에서는 배경 제거에 대해 알아보겠습니다.

배경 제거(Background Subtraction)

객체 추적을 위해 객체가 무엇인지, 어디 있는지부터 명확히 파악해야 합니다. 객체를 명확히 파악하기 위한 방법이 바로 배경 제거입니다. 배경 제거는 객체를 포함하는 영상에서 객체가 없는 배경 영상을 빼는 방법을 말합니다. 즉, 배경을 모두 제거해 객체만 남기는 방법입니다. 아래 그림을 보면 이해가 쉬울 겁니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://docs.opencv.org/3.4/d1/dc5/tutorial_background_subtraction.html

강 위에 배가 있는 영상에서 강만 있는 영상을 빼면 객체인 배만 남습니다.

배경 제거 함수

배경 제거를 구현하는 객체 생성 함수는 아래와 같습니다.

  • cv2.bgsegm.createBackgroundSubtractorMOG(history, nmixtures, backgroundRatio, noiseSigma)
    history=200: 히스토리 길이
    nmixtures=5: 가우시안 믹스처의 개수
    backgroundRatio=0.7: 배경 비율
    noiseSigma=0: 노이즈 강도 (0=자동)

이는 2001년 KadewTraKuPong과 Bowde의 논문(An improved adaptive background mixture model for real-time tracking with shadow detection)에 소개된 알고리즘을 구현한 함수입니다. 여러 가지 파라미터가 있지만 default 값으로 설정해도 됩니다. 추가 튜닝이 필요 없는 이상 아래의 apply() 함수 호출만으로 결과를 얻을 수 있습니다. 배경 제거 객체의 인터페이스 함수는 다음 두 가지가 있습니다.

  • foregroundmask = backgroundsubtractor.apply(img, foregroundmask, learningRate)
    img: 입력 영상
    foregroundmask: 전경 마스크
    learningRate=-1: 배경 훈련 속도(0~1, -1: 자동)
  • backgroundImage = backgroundsubtractor.getBackgroundImage(backgroundImage)
    backgroundImage: 훈련용 배경 이미지

cv2.bgsegm.createBackgroundSubtractorMOG() 함수를 활용하여 배경 제거 객체를 만들어, 실제 영상에서 배경을 제거해보겠습니다.

# BackgroundSubtractorMOG로 배경 제거 (track_bgsub_mog.py)

import numpy as np, cv2

cap = cv2.VideoCapture('../img/walking.avi')
fps = cap.get(cv2.CAP_PROP_FPS) # 프레임 수 구하기
delay = int(1000/fps)
# 배경 제거 객체 생성 --- ①
fgbg = cv2.bgsegm.createBackgroundSubtractorMOG()
while cap.isOpened():
    ret, frame = cap.read()
    if not ret:
        break
    # 배경 제거 마스크 계산 --- ②
    fgmask = fgbg.apply(frame)
    cv2.imshow('frame',frame)
    cv2.imshow('bgsub',fgmask)
    if cv2.waitKey(1) & 0xff == 27:
        break
cap.release()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

배경 제거 객체 생성시 파라미터 값은 모두 default 값으로 설정했습니다. 배경 제거 객체에 apply() 함수를 적용하여 배경이 제거된 전경 마스크를 얻었습니다. 

또 다른 배경 제거 객체 생성 함수는 다음과 같습니다.

  • cv2.createBackgroundSubtractorMOG2(history, varThreshold, detectShadows)
    history=500: 히스토리 개수
    varThreshold=16: 분산 임계 값
    detectShadows=True: 그림자 표시

이는 Z.Zivkovic의 2004년 논문(Improved adaptive Gausian mixture model for background subtraction)과 2006년 논문(Efficient Adaptive Density Estimation per Image Pixel for the Task of Background Subtraction)에 소개된 알고리즘을 구현한 함수입니다. 이 알고리즘은 영상의 각 픽셀에서 적절한 가우시안 분포 값을 선택합니다. 따라서 빛의 변화가 심한 영상에 적용하기 좋습니다. 또한, 그림자를 탐지할지 말지 선택할 수 있습니다. detectShadows=True로 설정하면 그림자를 표시하고 False로 설정하면 그림자를 표시하지 않습니다. 그림자는 회색으로 표시하고, 그림자를 표시하면 속도가 다소 느려집니다.

# BackgroundSubtractorMOG2 배경 제거 (track_bgsub_mog2.py)

import numpy as np, cv2

cap = cv2.VideoCapture('../img/walking.avi')
fps = cap.get(cv2.CAP_PROP_FPS) # 프레임 수 구하기
delay = int(1000/fps)
# 배경 제거 객체 생성 --- ①
fgbg = cv2.createBackgroundSubtractorMOG2()
while cap.isOpened():
    ret, frame = cap.read()
    if not ret:
        break
    # 배경 제거 마스크 계산 --- ②
    fgmask = fgbg.apply(frame)
    cv2.imshow('frame',frame)
    cv2.imshow('bgsub',fgmask)
    if cv2.waitKey(delay) & 0xff == 27:
        break
cap.release()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

배경 제거 객체 생성 함수만 다를 뿐 나머지 코드는 앞선 코드와 동일합니다. 실행 결과 그림자까지 표시되었습니다.

이번 포스팅에서는 객체 추적 방법인 광학 흐름에 관해 배워보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/08.match_track

광학 흐름(Optical Flow)

광학 흐름이란 영상 내 물체의 움직임 패턴을 말합니다. 이전 프레임과 다음 프레임 간 픽셀이 이동한 방향과 거리 분포입니다. 광학 흐름으로 영상 내 물체가 어느 방향으로 얼마만큼 움직였는지 파악할 수 있습니다. 더불어 추가 연산을 하면 물체의 움직임을 예측할 수도 있습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://en.wikipedia.org/wiki/Optical_flow

광학 흐름은 다음 두 가지 사실을 가정합니다.

1. 연속된 프레임 사이에서 움직이는 물체의 픽셀 강도(intensity)는 변함이 없다.
2. 이웃하는 픽셀은 비슷한 움직임을 갖는다.

광학 흐름을 계산하는 방법은 두 가지입니다. 일부 픽셀만 계산하는 희소(sparse) 광학 흐름과 영상 전체 픽셀을 모두 계산하는 밀집(dense) 광학 흐름입니다.

루카스-카나데(Lucas-Kanade) 알고리즘

광학 흐름은 이웃하는 픽셀이 비슷하게 움직인다고 가정합니다. 루카스-카나데 알고리즘은 이 가정을 이용하는 알고리즘입니다. 이웃하는 픽셀은 비슷한 움직임을 갖는다고 생각하고 광학 흐름을 파악합니다. 루카스-카나데 알고리즘은 작은 윈도(3 x 3 patch)를 사용하여 움직임을 계산합니다. 그래서 물체 움직임이 크면 문제가 생깁니다. 윈도 크기가 작기 때문입니다. 이 문제를 개선하기 위해 이미지 피라미드를 사용합니다. 이미지 피라미드 위쪽으로 갈수록(이미지가 작아질수록) 작은 움직임은 티가 안 나고 큰 움직임은 작은 움직임 같아 보입니다. 이렇게 큰 움직임도 감지할 수 있습니다. 

OpenCV는 루카스-카나데 알고리즘을 구현한 cv2.calcOpticalFlowPyrLK() 함수를 제공합니다.

  • nextPts, status, err = cv2.calcOpticalFlowPyrLK(prevImg, nextImg, prevPts, nextPts, status, err, wirnSize, maxLevel, criteria, flags, minEigThreshold)
    prevImg: 이전 프레임 영상
    nextImg: 다음 프레임 영상
    prevPts: 이전 프레임의 코너 특징점, cv2.goodFeaturesToTrack()으로 검출
    nextPst: 다음 프레임에서 이동한 코너 특징점
    status: 결과 상태 벡터, nextPts와 같은 길이, 대응점이 있으면 1, 없으면 0
    err: 결과 에러 벡터, 대응점 간의 오차
    winSize=(21,21): 각 이미지 피라미드의 검색 윈도 크기
    maxLevel=3: 이미지 피라미드 계층 수
    criteria=(COUNT+EPS, 30, 0.01): 반복 탐색 중지 요건 (cv2.TERM_CRITERIA_EPS: 정확도가 epsilon보다 작으면 중지, cv2.TERM_CRITERIA_MAX_ITER: max_iter 횟수를 채우면 중지, cv2.TERM_CRITERIA_COUNT: MAX_ITER와 동일, max_iter: 최대 반복 횟수, epsilon: 최소 정확도)
    flgs=0: 연산 모드 (0: prevPts를 nextPts의 초기 값으로 사용, cv2.OPTFLOW_USE_INITAL_FLOW: nextPts의 값을 초기 값으로 사용, cv2.OPTFLOW_LK_GET_MIN_EIGENVALS: 오차를 최소 고유 값으로 계산)
    minEigThreshold=1e-4: 대응점 계산에 사용할 최소 임계 고유 값

위 함수는 영상 내 픽셀 전체를 한번에 계산하지 않습니다. cv2.goodFeaturesToTrack() 함수로 얻은 특징점만 활용하여 계산합니다. prevImg와 nextImg 파라미터에는 이전 프레임과 다음 프레임을 전달하면 됩니다. prevPts 파라미터에는 이전 프레임에서 얻은 특징점을 전달합니다. 그러면 특징점이 다음 프레임에서 어디로 이동했는지 계산하여 nextPts로 반환합니다. 두 특징점이 서로 대응하면 status 변수가 1, 그렇지 않으면 0이 됩니다. 또한, maxLevel=0이면 이미지 피라미드를 사용하지 않습니다. 

다음은 calcOpticalFlowPyrLK() 함수를 활용하여 광학 흐름을 적용한 코드입니다.

# calcOpticalFlowPyrLK 추적 (track_opticalLK.py)

import numpy as np, cv2

cap = cv2.VideoCapture('../img/walking.avi')
fps = cap.get(cv2.CAP_PROP_FPS) # 프레임 수 구하기
delay = int(1000/fps)
# 추적 경로를 그리기 위한 랜덤 색상
color = np.random.randint(0,255,(200,3))
lines = None  #추적 선을 그릴 이미지 저장 변수
prevImg = None  # 이전 프레임 저장 변수
# calcOpticalFlowPyrLK 중지 요건 설정
termcriteria =  (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03)

while cap.isOpened():
    ret,frame = cap.read()
    if not ret:
        break
    img_draw = frame.copy()
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    # 최초 프레임 경우
    if prevImg is None:
        prevImg = gray
        # 추적선 그릴 이미지를 프레임 크기에 맞게 생성
        lines = np.zeros_like(frame)
        # 추적 시작을 위한 코너 검출  ---①
        prevPt = cv2.goodFeaturesToTrack(prevImg, 200, 0.01, 10)
    else:
        nextImg = gray
        # 옵티컬 플로우로 다음 프레임의 코너점  찾기 ---②
        nextPt, status, err = cv2.calcOpticalFlowPyrLK(prevImg, nextImg, \
                                        prevPt, None, criteria=termcriteria)
        # 대응점이 있는 코너, 움직인 코너 선별 ---③
        prevMv = prevPt[status==1]
        nextMv = nextPt[status==1]
        for i,(p, n) in enumerate(zip(prevMv, nextMv)):
            px,py = p.ravel()
            nx,ny = n.ravel()
            # 이전 코너와 새로운 코너에 선그리기 ---④
            cv2.line(lines, (px, py), (nx,ny), color[i].tolist(), 2)
            # 새로운 코너에 점 그리기
            cv2.circle(img_draw, (nx,ny), 2, color[i].tolist(), -1)
        # 누적된 추적 선을 출력 이미지에 합성 ---⑤
        img_draw = cv2.add(img_draw, lines)
        # 다음 프레임을 위한 프레임과 코너점 이월
        prevImg = nextImg
        prevPt = nextMv.reshape(-1,1,2)

    cv2.imshow('OpticalFlow-LK', img_draw)
    key = cv2.waitKey(delay)
    if key == 27 : # Esc:종료
        break
    elif key == 8: # Backspace:추적 이력 지우기
        prevImg = None
cv2.destroyAllWindows()
cap.release()
OpenCV 형상 인식 - OpenCV hyeongsang insig

cv2.goodFeatureToTrack() 함수로 이전 프레임의 특징점을 검출했습니다. cv2.calcOpticalFlowPyrLK() 함수로 광학 흐름을 계산해 다음 프레임의 특징점을 찾았습니다. 이전 프레임과 다음 프레임 특징점 중 잘 대응되는 특징점만 선별하여 선과 점으로 표시했습니다. 원본 이미지에 추적선을 합성하는 방식으로 표현했습니다. 그래야 추적선이 보입니다.

군나르 파너백(Gunner Farneback) 알고리즘

군나르 파너백 알고리즘은 밀집 방식으로 광학 흐름을 계산하는 알고리즘입니다. 위에서 설명했다시피 밀집 방식은 영상 전체의 픽셀을 활용해 광학 흐름을 계산하는 방식입니다. 이 알고리즘은 2003년 군나르 파너백의 논문(Two-Frame Motion Estimation Based on Polynomial Expansion)에 소개된 알고리즘입니다. 군나르 파너백 알고리즘을 구현하기 위해 OpenCV에서는 cv2.calOpticalFlowFarneback() 함수를 제공합니다.

  • flow = cv2.calcOpticalFlowFarneback(prev, next, flow, pyr_scale, levels, winsize, iterations, poly_n, poly_sigma, flags)
    prev, next: 이전, 이후 프레임
    flow: 광학 흐름 계산 결과, 각 픽셀이 이동한 거리 (입력과 동일한 크기)
    pyr_scale: 이미지 피라미드 스케일
    levels: 이미지 피라미드 개수
    winsize: 평균 윈도 크기
    iterations: 각 피라미드에서 반복할 횟수
    poly_n: 다항식 근사를 위한 이웃 크기, 5 또는 7
    poly_sigma: 다항식 근사에서 사용할 가우시안 시그마 (poly_n=5일 때는 1.1, poly_n=7일 때는 1.5)
    flags: 연산 모드 (cv2.OPTFLOW_USE_INITAL_FLOW: flow 값을 초기 값으로 사용, cv2.OPTFLOW_FARNEBACK_GAUSSIAN: 박스 필터 대신 가우시안 필터 사용)

밀집 광학 흐름은 희소 광학 흐름과 다르게 영상 전체 픽셀을 활용해 계산합니다. 그래서 추적할 특징점을 따로 전달할 필요가 없습니다. 다만, 전체 픽셀을 활용해 계산하므로 속도가 느립니다.

# calcOPticalFlowFarneback 추적 (track_optical_farneback.py)

import cv2, numpy as np

# 플로우 결과 그리기 ---①
def drawFlow(img,flow,step=16):
  h,w = img.shape[:2]
  # 16픽셀 간격의 그리드 인덱스 구하기 ---②
  idx_y,idx_x = np.mgrid[step/2:h:step,step/2:w:step].astype(np.int)
  indices =  np.stack( (idx_x,idx_y), axis =-1).reshape(-1,2)
  
  for x,y in indices:   # 인덱스 순회
    # 각 그리드 인덱스 위치에 점 그리기 ---③
    cv2.circle(img, (x,y), 1, (0,255,0), -1)
    # 각 그리드 인덱스에 해당하는 플로우 결과 값 (이동 거리)  ---④
    dx,dy = flow[y, x].astype(np.int)
    # 각 그리드 인덱스 위치에서 이동한 거리 만큼 선 그리기 ---⑤
    cv2.line(img, (x,y), (x+dx, y+dy), (0,255, 0),2, cv2.LINE_AA )


prev = None # 이전 프레임 저장 변수

cap = cv2.VideoCapture('../img/walking.avi')
fps = cap.get(cv2.CAP_PROP_FPS) # 프레임 수 구하기
delay = int(1000/fps)

while cap.isOpened():
  ret,frame = cap.read()
  if not ret: break
  gray = cv2.cvtColor(frame,cv2.COLOR_BGR2GRAY)
  # 최초 프레임 경우 
  if prev is None: 
    prev = gray # 첫 이전 프레임 --- ⑥
  else:
    # 이전, 이후 프레임으로 옵티컬 플로우 계산 ---⑦
    flow = cv2.calcOpticalFlowFarneback(prev,gray,None,\
                0.5,3,15,3,5,1.1,cv2.OPTFLOW_FARNEBACK_GAUSSIAN) 
    # 계산 결과 그리기, 선언한 함수 호출 ---⑧
    drawFlow(frame,flow)
    # 다음 프레임을 위해 이월 ---⑨
    prev = gray
  
  cv2.imshow('OpticalFlow-Farneback', frame)
  if cv2.waitKey(delay) == 27:
      break
cap.release()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

drawFlow() 함수로 16픽셀 간격으로 격자 모양의 점을 찍었습니다. 각 점에서 해당하는 픽셀이 이동한 만큼 선으로 표시했습니다. 희소 광학 흐름과 다르게 밀집 광학 흐름은 영상 전체에서 일어나는 움직임을 감지할 수 있습니다.

이번 포스팅에서는 객체 추적을 위한 Tracking API에 대해 배워보겠습니다. 이번 포스팅 역시 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리한 것임을 밝힙니다.

코드: github.com/BaekKyunShin/OpenCV_Project_Python/tree/master/08.match_track

Tracking API

OpenCV에서는 객체 추적을 위한 Tracking API를 제공합니다. Tracking API를 이용하면 쉽게 객체 추적을 할 수 있습니다. 알고리즘 이론을 몰라도 됩니다. 추적하고자 하는 객체만 지정해주면 API가 알아서 객체를 추적해줍니다. 편리하죠? OpenCV에서 제공하는 Tracking API생성자는 아래와 같습니다. 생성자는 알고리즘에 따라 다양합니다.

tracker = cv2.TrackerBoosting_create(): AdaBoost 알고리즘 기반
tracker = cv2.TrackerMIL_create(): MIL(Multiple Instance Learning) 알고리즘 기반
tracker = cv2.TrackerKCF_create(): KCF(Kernelized Correlation Filters) 알고리즘 기반
tracker = cv2.TrackerTLD_create(): TLD(Tracking, Learning and Detection) 알고리즘 기반
tracker = cv2.TrackerMedianFlow_create(): 객체의 전방향/역방향을 추적해서 불일치성을 측정
tracker = cv2.TrackerGOTURN_cretae(): CNN(Convolutional Neural Networks) 기반 - OpenCV 3.4 버전에서는 버그로 동작이 안 됨tracker = cv2.TrackerCSRT_create(): CSRT(Channel and Spatial Reliability)tracker = cv2.TrackerMOSSE_create(): 내부적으로 그레이 스케일 사용

저도 각 알고리즘이 구체적으로 어떻게 동작하는지 모릅니다. 그냥 '이런 알고리즘이 있구나'하고 넘어가도 무방합니다. 생성한 Tracker는 init() 함수로 초기화할 수 있습니다. init() 함수의 파라미터로 두 가지를 전달해야 합니다. 입력 영상과 추적 대상 객체가 있는 좌표입니다. 

  • retval = cv2.Tracker.init(img, boundingBox): Tracker 초기화
    img: 입력 영상
    boundingBox: 추적 대상 객체가 있는 좌표 (x, y)

초기화 후 새로운 영상 프레임에서 추적 대상 객체의 위치를 찾기 위해 update() 함수를 호출해야 합니다.

  • retval, boundingBox = cv2.Tracker.update(img): 새로운 프레임에서 추적 대상 객체 위치 찾기
    img: 새로운 프레임 영상
    retval: 추적 성공 여부
    boundingBox: 새로운 프레임에서의 추적 대상 객체의 새로운 위치 (x, y, w, h)

아래 코드는 Tracking API를 이용해서 객체를 추적하는 예제입니다.

# Tracker APIs (track_trackingAPI.py)

import cv2

# 트랙커 객체 생성자 함수 리스트 ---①
trackers = [cv2.TrackerBoosting_create,
            cv2.TrackerMIL_create,
            cv2.TrackerKCF_create,
            cv2.TrackerTLD_create,
            cv2.TrackerMedianFlow_create,
            cv2.TrackerGOTURN_create, #버그로 오류 발생
            cv2.TrackerCSRT_create,
            cv2.TrackerMOSSE_create]
trackerIdx = 0  # 트랙커 생성자 함수 선택 인덱스
tracker = None
isFirst = True

video_src = 0 # 비디오 파일과 카메라 선택 ---②
video_src = "../img/highway.mp4"
cap = cv2.VideoCapture(video_src)
fps = cap.get(cv2.CAP_PROP_FPS) # 프레임 수 구하기
delay = int(1000/fps)
win_name = 'Tracking APIs'
while cap.isOpened():
    ret, frame = cap.read()
    if not ret:
        print('Cannot read video file')
        break
    img_draw = frame.copy()
    if tracker is None: # 트랙커 생성 안된 경우
        cv2.putText(img_draw, "Press the Space to set ROI!!", \
            (100,80), cv2.FONT_HERSHEY_SIMPLEX, 0.75,(0,0,255),2,cv2.LINE_AA)
    else:
        ok, bbox = tracker.update(frame)   # 새로운 프레임에서 추적 위치 찾기 ---③
        (x,y,w,h) = bbox
        if ok: # 추적 성공
            cv2.rectangle(img_draw, (int(x), int(y)), (int(x + w), int(y + h)), \
                          (0,255,0), 2, 1)
        else : # 추적 실패
            cv2.putText(img_draw, "Tracking fail.", (100,80), \
                        cv2.FONT_HERSHEY_SIMPLEX, 0.75,(0,0,255),2,cv2.LINE_AA)
    trackerName = tracker.__class__.__name__
    cv2.putText(img_draw, str(trackerIdx) + ":"+trackerName , (100,20), \
                 cv2.FONT_HERSHEY_SIMPLEX, 0.75, (0,255,0),2,cv2.LINE_AA)

    cv2.imshow(win_name, img_draw)
    key = cv2.waitKey(delay) & 0xff
    # 스페이스 바 또는 비디오 파일 최초 실행 ---④
    if key == ord(' ') or (video_src != 0 and isFirst): 
        isFirst = False
        roi = cv2.selectROI(win_name, frame, False)  # 초기 객체 위치 설정
        if roi[2] and roi[3]:         # 위치 설정 값 있는 경우
            tracker = trackers[trackerIdx]()    #트랙커 객체 생성 ---⑤
            isInit = tracker.init(frame, roi)
    elif key in range(48, 56): # 0~7 숫자 입력   ---⑥
        trackerIdx = key-48     # 선택한 숫자로 트랙커 인덱스 수정
        if bbox is not None:
            tracker = trackers[trackerIdx]() # 선택한 숫자의 트랙커 객체 생성 ---⑦
            isInit = tracker.init(frame, bbox) # 이전 추적 위치로 추적 위치 초기화
    elif key == 27 : 
        break
else:
    print( "Could not open video")
cap.release()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

코드를 처음 실행하면 화면이 멈춰있을 겁니다. 먼저 추적을 원하는 객체를 드래그합니다. 다음으로 스페이스(space) 키를 누릅니다. 그러면 알아서 재생되고, 객체 추적을 시작합니다. 키보드 0~7 숫자 키를 눌러 트랙커 알고리즘을 선택할 수 있습니다. 다만 5를 누르면 오류가 뜨면서 종료가 될 겁니다. OpenCV 3.4 버전에서 cv2.TrackerGOTURN_create() 알고리즘에 버그가 있기 때문입니다. 5를 제외하고 0부터 7까지 숫자를 눌러봅시다. 화면 상단의 0:TrackerBoosting, 1:TrackerMIL 등이 뜰 겁니다. 현재 어떤 트랙커로 객체 추적을 하고 있는지를 나타내는 표시입니다.

이번 포스팅에서는 HOG 디스크립터에 대해 알아보겠습니다. 이번에도 '파이썬으로 만드는 OpenCV 프로젝트(이세우 저)'를 정리했습니다.

기울기 벡터(Gradient Vectors)

기울기 벡터란 영상 내 하나의 픽셀을 기준으로 주변 픽셀에 대한 기울기를 나타내는 벡터를 의미합니다. 말이 좀 어려운데 아래 예시 그림을 보겠습니다. 펭귄 머리 부분을 확대해보겠습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://chrisjmccormick.wordpress.com/2013/05/07/gradient-vectors/

빨간 점으로 표시된 픽셀을 기준으로 왼쪽의 Gray Scale 값은 56이고, 오른쪽의 값은 94입니다. Gray Scale의 경우 0이면 검은색이고 255이면 흰색입니다. 따라서 Gray Scale 값 94가 56보다 밝습니다. 위 그림에서 실제로도 오른쪽이 밝죠? 이때 빨간 점으로 표시된 픽셀 입장에서 x축 방향의 변화량(gx)은 (94 - 56) = 38이 됩니다. 이 값이 x축 방향 기울기 변화량입니다. 

이번엔 y축 방향으로 생각해 보시죠. 빨간 점을 기준으로 위쪽 Gray Scale 값은 93이고, 아래쪽 값은 55입니다. 따라서 y축 방향의 기울기 변화량은 (93 - 55) = 38입니다. x축 방향 기울기 변화량, y축 방향 기울기 변화량이 모두 38이네요.

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://chrisjmccormick.wordpress.com/2013/05/07/gradient-vectors/

x축 방향 기울기 변화량, y축 방향 기울기 변화량을 함께 표현한 값이 기울기 벡터(gradient vector)입니다. 따라서 빨간점 기준으로 기울기 벡터는 다음과 같습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

이 기울기 벡터의 크기와 방향(각도)은 다음과 같이 계산합니다. 

OpenCV 형상 인식 - OpenCV hyeongsang insig

크기는 53.74이고 방향(각도)은 45도입니다. 크기와 방향을 벡터로 표시하면 다음과 같습니다.

OpenCV 형상 인식 - OpenCV hyeongsang insig

픽셀(Pixels), 셀(Cells), 블록(Blocks)

HOG 디스크립터에 관해 살펴보기 전에 픽셀, 셀, 블록, 윈도의 개념을 알아야 합니다. 간단합니다. 픽셀은 말 그대로 영상 내 하나의 픽셀 값을 의미합니다. 이 픽셀들을 몇 개 묶어서 소그룹으로 만든 것이 셀입니다. 다시 셀을 몇 개 묶어서 그룹으로 만든 것이 블록입니다. 다시 말하면 하나의 블록 안에 셀 여러 개가 있고, 하나의 셀 안에 픽셀 여러 개가 있습니다.

HOG(Histogram of Oriented Gradient)

HOG는 보행자 검출을 위해 만들어진 특징 디스크립터입니다. HOG는 이미지 경계의 기울기 벡터 크기(magnitude)와 방향(direction)을 히스토그램으로 나타내 계산합니다. 

HOG 디스크립터를 만들기 위해서는 영상 속에서 검출하고자 하는 영역을 잘라내야 합니다. 이렇게 잘라낸 영역을 윈도(window)라고 합니다. 일반적으로 보행자를 검출하기 위해서는 윈도 사이즈를 64 x 128 픽셀 크기로 합니다. 해당 윈도에 소벨 필터를 적용해 경계의 기울기 gx, gy를 구하고, 기울기의 방향(direction)과 크기(magnitude)를 계산합니다. 이 과정을 코드로 나타내면 다음과 같습니다.

img = cv2.imread('img.png')
img = np.float(img)

gx = cv2.Sobel(img, cv2.CV_32F, 1, 0)
gy = cv2.Sobel(img, cv2.CV_32F, 0, 1)
magnitude, angle = cv2.cartToPolar(gx, gy)

아래 예시에서는 윈도 사이즈가 8 x 16 cells 입니다. 하나의 셀(cell)은 5 x 5 픽셀(pixels)로 구성되어 있습니다. 그러므로 윈도 사이즈는 (8x5) x (16x5) = 40 x 80 pixels입니다. 하나의 블록(block)은 2 x 2 셀(cells)로 구성되어 있습니다. 위에서 설명했다시피 픽셀이 모여서 셀이 되고, 셀이 모여서 블록이 됩니다.

그리고 각 픽셀을 기준으로 기울기 벡터를 구해야 합니다. 아래 그림에서 화살표로 표시된 것이 기울기 벡터입니다. 기울기 벡터의 크기와 방향을 히스토그램으로 나타낸 것도 볼 수 있습니다. 기울기 벡터의 방향을 계급(bin)으로 하고 크기를 값으로 누적한 히스토그램입니다. 계급(bin)은 180도를 20도씩 총 9개의 구간으로 나누어 사용합니다. 360도가 아닌 180도로 하는 이유는 기울기의 양수와 음수가 같은 방향을 나타내기 때문입니다. 이때 윈도 전체를 하나의 히스토그램으로 계산하는 것이 아니라 하나의 셀을 기준으로 히스토그램을 계산합니다. 

OpenCV 형상 인식 - OpenCV hyeongsang insig

출처: https://www.researchgate.net/figure/Histogram-of-Oriented-Gradient-12_fig3_313369595

보행자 검출에서는 윈도 사이즈를 64 x 128 픽셀(pixels), 셀의 크기를 일반적으로 8 x 8 픽셀(pixels)로 정합니다.

히스토그램 계산을 마치면 정규화(normalization) 과정을 거쳐야 합니다. 경계 값 기울기는 밝기에 민감합니다. 민감성을 없애주려고 정규화를 하는 겁니다. 정규화를 적용하려면 윈도를 특정 크기로 나누어야 합니다. 이렇게 나누는 영역을 블록(block)이라고 합니다. 블록 크기는 일반적으로 셀 크기의 2배입니다.  8 x 8 픽셀의 셀을 가지고 있다면 블록은 16 x 16 픽셀로 정합니다. 각 블록은 전체 윈도를 순회하면서 정규화를 합니다. 이때 겹치는 부분이 발생하는데 이 부분을 블록 스트라이드(blcok stride)라고 합니다. 64 x 128 픽셀의 윈도에서 16 x 16 픽셀의 블록이 '8 x 8 픽셀의 블록 스트라이드'를 가졌다면, 정규화를 계산하는 횟수는 총 7 x 15= 105번입니다. {64/(16-8) - 1} x {128/(16-8) - 1} = 105이기 때문입니다.

종합하면, 원본 영상에서 보행자에 해당하는 부분을 자른 영역을 윈도(window)라고 합니다. 사람을 검출할 때는 보통 64 x 128 크기로 윈도를 정합니다. 이렇게 자른 윈도에서 각 픽셀에 대해 기울기 벡터를 구해줍니다. 그리고 특정 영역을 중심으로 기울기 벡터 크기와 방향에 대한 히스토그램을 구해야 합니다. 히스토그램을 계산하기 위한 영역을 셀(cell)이라고 합니다. 셀은 보통 8 x 8 크기로 정합니다. 히스토그램을 계산한 뒤 정규화해야 합니다. 정규화하려면 윈도를 다시 잘게 나누어야 하는데, 이를 블록(block)이라고 합니다. 블록 크기는 대게 셀 크기의 2배로 정합니다. 블록은 전체 윈도를 순회하면서 정규화하는데, 이때 블록이 한번에 이동하는 거리를 블록 스트라이드(block stride)라고 합니다.

HOG 디스크립터를 활용한 보행자 인식

OpenCV에서는 HOG 디스크립터를 계산하기 위해 아래 함수를 제공합니다.

  • descriptor = cv2.HOGDescriptor(winSize, blockSize, blockStride, cellSize, nbins): HOG 디스크립터 추출기 생성
    winSize: 윈도 크기, HOG 추출 영역
    blockSize: 블록 크기, 정규화 영역
    blockStride: 정규화 블록 겹침 크기
    cellSize: 셀 크기, 히스토그램 계산 영역
    nbins: 히스토그램 계급 수
    descriptor: HOG 특징 디스크립터 추출기
  • hog = descriptor.compute(img): HOG 계산
    img: 계산 대상 이미지
    hog: HOG 특징 디스크립터 결과

OpenCV는 보행자 인식을 위한 사전 훈련 API를 제공합니다. cv2.HOGDescriptor는 HOG 디스크립터를 계산해 줄 수도 있고, 미리 훈련된 SVM 모델(pretrained SVM model)을 전달받아 보행자를 추출해줄 수도 있습니다.

  • scvmdetector = cv2.HOGDescriptor_getDefaultPeopleDetector(): 64 x 128 윈도 크기로 훈련된 모델
  • scvmdetector = cv2.HOGDescriptor_getDaimlerPeopleDetector(): 48 x 96 윈도 크기로 훈련된 모델
  • descriptor = cv2.HOGDescriptor(winSize, blockSize, blockStride, cellSize, nbins): HOG 생성
  • descriptor.setSVMDetector(svmdetector): 훈련된 SVM 모델 설정
  • rects, weights = descriptor.detectMultiScale(img): 객체 검출
    img: 검출하고자 하는 이미지
    rects: 검출된 결과 영역 좌표 N x 4 (x, y, w, h)
    weights: 검출된 결과 계수 N x 1

HOG 디스크립터를 이용해 보행자를 인식해보겠습니다.

# HOG-SVM 보행자 검출 (svm_hog_pedestrian.py)

import cv2

# default 디덱터를 위한 HOG 객체 생성 및 설정--- ①
hogdef = cv2.HOGDescriptor()
hogdef.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())

# dailer 디덱터를 위한 HOG 객체 생성 및 설정--- ②
hogdaim  = cv2.HOGDescriptor((48,96), (16,16), (8,8), (8,8), 9)
hogdaim.setSVMDetector(cv2.HOGDescriptor_getDaimlerPeopleDetector())

cap = cv2.VideoCapture('../img/walking.avi')
mode = True  # 모드 변환을 위한 플래그 변수 
print('Toggle Space-bar to change mode.')
while cap.isOpened():
    ret, img = cap.read()
    if ret :
        if mode:
            # default 디텍터로 보행자 검출 --- ③
            found, _ = hogdef.detectMultiScale(img)
            for (x,y,w,h) in found:
                cv2.rectangle(img, (x,y), (x+w, y+h), (0,255,255))
        else:
            # daimler 디텍터로 보행자 검출 --- ④
            found, _ = hogdaim.detectMultiScale(img)
            for (x,y,w,h) in found:
                cv2.rectangle(img, (x,y), (x+w, y+h), (0,255,0))
        cv2.putText(img, 'Detector:%s'%('Default' if mode else 'Daimler'), \
                        (10,50 ), cv2.FONT_HERSHEY_DUPLEX,1, (0,255,0),1)
        cv2.imshow('frame', img)
        key = cv2.waitKey(1) 
        if key == 27:
            break
        elif key == ord(' '):
            mode = not mode
    else:
        break
cap.release()
cv2.destroyAllWindows()
OpenCV 형상 인식 - OpenCV hyeongsang insig

Default 보행자 검출기와 Daimler 보행자 검출기를 이용해 보행자를 검출해봤습니다. 스페이스 바를 누르면 검출기를 바꿀 수 있습니다. 노란색 박스로 보행자를 인식한 게 Default 보행자 검출기이고, 초록색 박스로 보행자를 인식한 게 Daimler 보행자 검출기입니다. Default 보행자 검출기는 불필요한 검출이 적은 대신 멀리 있는 작은 보행자는 검출하지 못합니다. 반면 Daimler 보행자 검출기는 멀리 있는 작은 보행자도 검출하지만 삼각대나 건물 그림자도 보행자로 인식하네요. 둘 다 장단점이 있습니다.