내가 보려고 정리한 OpenCV 정리 (3) with GPT

OpenCV에서 관심영역, 이진화, 기하 변환을 다루는 방식

여기서는 단일 영상 처리 기법 몇 개를 따로따로 익히는 수준을 넘어서, 관심영역 추출, 임계값 기반 분리, 아핀 변환, 투시 변환까지 하나의 흐름으로 정리한다. 공통점은 모두 이미지 배열의 좌표계를 직접 다룬다는 점이며, 차이는 픽셀을 선택하느냐, 분류하느냐, 재배치하느냐에 있다. 특히 이번 정리는 메서드와 함수가 입력을 어떻게 받아 어떤 결과로 바꾸는지, 그리고 shape이 어디서 유지되고 어디서 바뀌는지에 초점을 둔다.


1. 관심영역 ROI는 무엇인가

관심영역은 전체 이미지 중 실제로 다루고 싶은 부분만 잘라내어 별도로 처리하는 방식이다. 이미지 전체를 매번 대상으로 삼으면 연산량이 불필요하게 커지고, 문제의 핵심이 되는 부분만 분리하기도 어렵다. 얼굴, 문서, 문자, 물체 일부만 따로 다루고 싶을 때 가장 먼저 등장하는 기본 단위가 ROI다.

왜 필요한가

이미지 처리는 대부분 "전체에서 필요한 부분만 골라서 다룬다"는 구조로 시작한다.
예를 들어 다음과 같은 작업은 ROI 없이 진행하기 어렵다.

  • 문서 스캔에서 문서 외곽만 추출
  • OCR 전처리에서 글자 영역만 분리
  • 객체 추적에서 이전 프레임의 대상 위치만 재검색
  • 편집 기능에서 사용자가 선택한 부분만 복사, 이동, 변형

ROI를 이해하면 이후의 이진화, 변환, 투시 보정도 모두 "어느 좌표를 대상으로 하는가"라는 관점으로 연결된다.

어떻게 구현되는가

NumPy 배열 슬라이싱으로 ROI를 만든다. OpenCV 이미지는 결국 NumPy 배열이므로, 행과 열 범위를 잘라내면 곧바로 부분 영상이 된다.

import cv2

img = cv2.imread('./images/sun.jpg')

x = 183
y = 20
w = 125
h = 113

roi = img[y:y+h, x:x+w]
roi = roi.copy()

img[y:y+h, x+w:x+w+w] = roi
cv2.rectangle(img, (x, y), (x+w+w, y+h), (0, 255, 0), 3)

# img.shape -> (H, W, 3)
# roi.shape -> (113, 125, 3)
# img[y:y+h, x+w:x+w+w].shape -> (113, 125, 3)

주요 메서드와 함수의 역할

cv2.imread

디스크에서 이미지를 읽어 NumPy 배열로 반환한다.

img = cv2.imread('./images/sun.jpg')

# 입력  : 파일 경로
# 출력  : uint8 ndarray
# shape : (높이, 너비, 채널수) -> 컬러면 일반적으로 (H, W, 3)

배열 슬라이싱

ROI의 핵심이다.
이미지 배열은 세로가 먼저, 가로가 나중이다. 즉 순서는 x, y가 아니라 y, x다.

roi = img[y:y+h, x:x+w]

# 입력  : 원본 이미지 img
# 출력  : 부분 배열
# shape : (h, w, 3)

초보자가 가장 자주 헷갈리는 지점은 다음 두 가지다.

  1. 좌표를 지정할 때는 보통 (x, y)를 쓰지만, 배열 슬라이싱은 [y, x] 순서다.
  2. 슬라이싱 결과는 복사본이 아니라 뷰일 수 있다.

copy

ROI를 복사본으로 만든다. 이 한 줄이 중요한 이유는, 슬라이싱한 결과를 그대로 쓰면 원본 배열과 메모리를 공유할 수 있기 때문이다. 그 상태에서 ROI를 수정하면 원본도 함께 바뀐다.

roi = roi.copy()

# 입력  : roi ndarray
# 출력  : 독립된 메모리를 가진 새 ndarray
# shape : 기존과 동일

cv2.rectangle

지정한 두 점을 대각선 꼭짓점으로 하는 사각형을 그린다. ROI 자체를 추출하는 기능이 아니라, 사용자가 선택된 영역을 시각적으로 확인하도록 돕는 표시용 함수다.

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

# 입력  : 이미지, 시작점, 끝점, 색상(BGR), 두께
# 출력  : 반환값 없이 img 배열에 직접 그림
# shape : img shape 유지

입력과 출력의 shape 변화

img = cv2.imread('./images/sun.jpg')
# img.shape == (H, W, 3)

roi = img[y:y+h, x:x+w]
# roi.shape == (h, w, 3) == (113, 125, 3)

img[y:y+h, x+w:x+w+w] = roi
# 좌변과 우변 shape이 동일해야 대입 가능
# (113, 125, 3) <- (113, 125, 3)

AI가 추천하는 심화예제

ROI를 잘라서 단순 복사하는 대신 다음 확장을 시도해볼 수 있다.

  • ROI를 그레이스케일로 변환한 뒤 원본에 다시 삽입
  • ROI만 블러 처리하여 배경과 구분
  • ROI를 다른 프레임으로 추적하는 템플릿 매칭 연결
  • 여러 ROI를 리스트로 관리하여 배치 처리

2. 마우스로 ROI를 선택하는 구조

정적 ROI는 좌표가 이미 정해져 있을 때만 쓸 수 있다. 실제 응용에서는 사용자가 직접 마우스로 영역을 지정하거나, 선택 결과를 후속 처리에 넘겨야 한다. 이때 핵심은 이벤트 기반 구조다. 마우스 입력에 따라 상태를 저장하고, 이동 중에는 임시 화면을 그려주고, 버튼을 놓는 순간 최종 ROI를 확정한다.

왜 필요한가

문서 스캔, 크롭 도구, 라벨링 툴, 객체 추출 같은 기능은 모두 사용자의 좌표 입력이 필요하다. 하드코딩된 x, y, w, h는 학습용 예제로는 충분하지만 실제 기능으로는 부족하다. ROI selector는 좌표를 사람이 직접 결정하도록 만들어 준다.

어떻게 구현되는가

이 구조는 세 단계로 요약된다.

  1. 마우스 버튼을 누른 순간 시작 좌표 저장
  2. 드래그하는 동안 복사 이미지 위에 사각형을 계속 갱신
  3. 버튼을 떼는 순간 최종 폭과 높이를 계산해 ROI 확정
oldx = oldy = w = h = 0
color = (255, 0, 0)
isDrag = False
img_copy = None

def on_mouse(event, x, y, flags, param):
    global oldx, oldy, w, h, isDrag, img_copy

    if event == cv2.EVENT_LBUTTONDOWN:
        isDrag = True
        oldx, oldy = x, y

    elif event == cv2.EVENT_MOUSEMOVE:
        if isDrag:
            img_copy = img.copy()
            cv2.rectangle(img_copy, (oldx, oldy), (x, y), color, 3)
            cv2.imshow('img', img_copy)

    elif event == cv2.EVENT_LBUTTONUP:
        if isDrag:
            isDrag = False
            if x > oldx and y > oldy:
                w, h = x - oldx, y - oldy
                roi = img[oldy:oldy+h, oldx:oldx+w]
                cv2.imshow('ROI', roi)

# roi.shape -> (h, w, 3)

주요 메서드와 함수의 역할

cv2.setMouseCallback

특정 창에서 발생하는 마우스 이벤트를 콜백 함수로 연결한다.

cv2.setMouseCallback('img', on_mouse)

# 입력  : 창 이름, 콜백 함수
# 출력  : 없음
# 역할  : 'img' 창에서 발생하는 마우스 이벤트를 on_mouse로 전달

on_mouse(event, x, y, flags, param)

사용자가 직접 정의한 이벤트 처리 함수다. OpenCV가 호출한다.
입력값의 의미를 명확히 이해해야 이후 인터랙션 기능을 확장할 수 있다.

def on_mouse(event, x, y, flags, param):
    pass

# event : 어떤 마우스 이벤트가 발생했는지
# x, y  : 이벤트가 발생한 현재 좌표
# flags : 추가 버튼/키 상태
# param : setMouseCallback에서 넘길 수 있는 사용자 데이터

cv2.EVENT_LBUTTONDOWN / MOUSEMOVE / LBUTTONUP

이 세 이벤트가 드래그 인터페이스의 기본 뼈대다.

  • LBUTTONDOWN: 시작점 기록
  • MOUSEMOVE: 드래그 중 시각화
  • LBUTTONUP: 최종 좌표 확정

img.copy

드래그 중 사각형을 새로 그릴 때 원본 위에 계속 덧그리지 않기 위해 매 프레임 복사본을 만든다.
이 처리가 없으면 마우스를 움직일 때마다 사각형 자국이 누적된다.

입력과 출력의 shape 변화

img.shape
# (H, W, 3)

img_copy = img.copy()
# (H, W, 3)

roi = img[oldy:oldy+h, oldx:oldx+w]
# (h, w, 3)

# 예를 들어 시작점 (100, 50), 끝점 (240, 180)이라면
# w = 140, h = 130
# roi.shape == (130, 140, 3)

오해하기 쉬운 포인트

드래그를 왼쪽 위에서 오른쪽 아래로만 허용하고 있다.

if x > oldx and y > oldy:

이 조건 때문에 반대 방향 드래그는 ROI가 생성되지 않는다. 실전에서는 min, max를 사용해 방향과 무관하게 좌표를 정규화하는 편이 더 안전하다.

AI가 추천하는 심화예제

  • 드래그 방향과 상관없이 ROI 선택되도록 좌표 정규화
  • 여러 개의 ROI를 연속으로 선택해서 저장
  • 선택된 ROI를 즉시 이진화 또는 엣지 검출에 연결
  • 마우스 우클릭으로 마지막 ROI 삭제 기능 추가

3. 이진화는 무엇이고 threshold는 무엇을 하는가

이진화는 회색조 영상의 각 픽셀을 두 값으로 나누는 과정이다. 보통 0과 255를 사용하며, 사실상 "배경과 전경을 분리하는 가장 단순한 분류기"라고 볼 수 있다. 픽셀 단위 분류이기 때문에 OCR, 문서 처리, 세포 검출, 마스크 생성 같은 작업의 출발점이 된다.

왜 필요한가

컬러나 그레이스케일 영상은 정보량이 많지만, 어떤 문제에서는 오히려 너무 많다. 예를 들어 문자를 읽고 싶다면 글자 영역과 배경만 구분되면 충분할 수 있다. 이진화는 그런 상황에서 불필요한 명암 변동을 줄이고 분석 대상을 선명하게 만든다.

단순 임계값 이진화는 다음 규칙으로 정의할 수 있다.

[
g(x, y)=
\begin{cases}
255, & f(x, y) > T
0, & f(x, y) \le T
\end{cases}
]

여기서 $f(x, y)$는 입력 픽셀 값, $T$는 임계값, $g(x, y)$는 결과 픽셀 값이다.

어떻게 구현되는가

import cv2
import matplotlib.pyplot as plt

img = cv2.imread('./images/cells.png', cv2.IMREAD_GRAYSCALE)
hist = cv2.calcHist([img], [0], None, [256], [0, 256])

a, dst1 = cv2.threshold(img, 100, 255, cv2.THRESH_BINARY)
b, dst2 = cv2.threshold(img, 50, 255, cv2.THRESH_BINARY)
c, dst3 = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)

# img.shape  -> (H, W)
# dst1.shape -> (H, W)
# dst2.shape -> (H, W)
# dst3.shape -> (H, W)
# hist.shape -> (256, 1)

같은 이미지에 서로 다른 임계값을 적용해 결과를 비교하고, 마지막에는 Otsu 방법으로 자동 임계값까지 계산한다.

주요 메서드와 함수의 역할

cv2.imread(..., cv2.IMREAD_GRAYSCALE)

이진화는 보통 단일 채널 영상에서 시작한다. 그레이스케일로 읽으면 각 픽셀이 0부터 255 사이의 밝기 값 하나만 가진다.

img = cv2.imread('./images/cells.png', cv2.IMREAD_GRAYSCALE)

# 입력  : 파일 경로, 읽기 옵션
# 출력  : 2차원 ndarray
# shape : (H, W)

cv2.calcHist

픽셀값 분포를 계산한다. Otsu 방법을 이해하려면 히스토그램이 왜 중요한지 알아야 한다. 히스토그램이 두 개의 봉우리를 가지면, 그 사이 어딘가가 임계값 후보가 되기 쉽다.

hist = cv2.calcHist([img], [0], None, [256], [0, 256])

# 입력  : 이미지 리스트, 채널 인덱스, 마스크, bin 수, 범위
# 출력  : 히스토그램 배열
# shape : (256, 1)

cv2.threshold

이 파일군에서 가장 중요한 함수 중 하나다.
반환값이 두 개라는 점을 정확히 기억해야 한다.

ret, dst = cv2.threshold(src, thresh, maxval, type)

# src    : 입력 영상, 일반적으로 (H, W)
# thresh : 임계값
# maxval : 조건을 만족할 때 줄 값, 보통 255
# type   : 이진화 방식
# ret    : 실제 사용된 임계값
# dst    : 이진화 결과 영상

일반 이진화

a, dst1 = cv2.threshold(img, 100, 255, cv2.THRESH_BINARY)

# 입력 shape  : (H, W)
# 출력 shape  : dst1 -> (H, W)
# a == 100.0

Otsu 이진화

c, dst3 = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)

# thresh 자리에 0을 넣지만
# 실제 임계값은 Otsu 알고리즘이 계산하여 ret에 반환
# c == 자동 계산된 threshold 값
# dst3.shape == (H, W)

Otsu 방법은 클래스 간 분산이 최대가 되는 임계값을 선택한다. 직관적으로는 두 집단을 가장 잘 분리하는 경계값을 찾는 방식이다.

입력과 출력의 shape 변화

img = cv2.imread(..., cv2.IMREAD_GRAYSCALE)
# (H, W)

ret, dst = cv2.threshold(img, 100, 255, cv2.THRESH_BINARY)
# ret -> scalar
# dst -> (H, W)

hist = cv2.calcHist([img], [0], None, [256], [0, 256])
# (256, 1)

오해하기 쉬운 포인트

threshold의 첫 번째 반환값은 결과 이미지가 아니다. 실제 사용된 임계값이다.
특히 Otsu를 사용할 때는 이 값이 자동으로 계산된다는 점이 중요하다.

AI가 추천하는 심화예제

  • 같은 이미지에 THRESH_BINARY_INV를 적용해 전경/배경 반전 비교
  • 블러 후 Otsu 적용과 원본 Otsu 결과 비교
  • 히스토그램을 기준으로 왜 특정 임계값이 잘 동작하는지 수동 분석
  • ROI 내부에만 threshold 적용해서 부분 분할 실험

4. 전역 이진화와 지역 이진화는 무엇이 다른가

전역 이진화는 이미지 전체에 하나의 임계값을 적용한다. 반면 지역 이진화는 영역별로 서로 다른 임계값을 사용한다. 조명 변화가 크거나 배경이 고르지 않은 경우에는 전체에 하나의 값만 적용하는 방식이 쉽게 무너진다. 이런 문제를 해결하기 위해 블록 단위 Otsu, 적응형 이진화 같은 방법이 필요해진다.

왜 필요한가

스도쿠처럼 화면 일부는 밝고 일부는 어두운 이미지에서는 전체 임계값 하나가 모든 영역에 동시에 잘 맞기 어렵다.
전역 threshold는 어느 쪽에는 과하게 밝고, 다른 쪽에는 과하게 어둡게 작동할 수 있다.

지역 기반 접근은 "각 지역의 통계는 다를 수 있다"는 전제를 둔다. 문서 그림자, 비균일 조명, 카메라 촬영 각도 변화에 특히 중요하다.

어떻게 구현되는가

import cv2
import numpy as np

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

a, dst1 = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)

dst2 = np.zeros(img.shape, np.uint8)
bw = img.shape[1] // 4
bh = img.shape[0] // 4

for y in range(4):
    for x in range(4):
        img_ = img[y*bh:(y+1)*bh, x*bw:(x+1)*bw]
        dst_ = dst2[y*bh:(y+1)*bh, x*bw:(x+1)*bw]
        cv2.threshold(img_, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU, dst_)

dst3 = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
                             cv2.THRESH_BINARY, 9, 6)
dst4 = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                             cv2.THRESH_BINARY, 9, 6)

# img_.shape, dst_.shape -> (bh, bw)
# dst1.shape, dst2.shape, dst3.shape, dst4.shape -> (H, W)

주요 메서드와 함수의 역할

np.zeros(img.shape, np.uint8)

결과를 담을 빈 이미지를 생성한다.
지역 이진화는 각 블록을 따로 계산한 뒤 같은 위치에 다시 써 넣어야 하므로, 최종 결과 버퍼가 필요하다.

dst2 = np.zeros(img.shape, np.uint8)

# 입력  : shape, dtype
# 출력  : 0으로 채워진 배열
# shape : (H, W)

블록 슬라이싱

이미지를 4×4로 나누어 각 블록에 대해 Otsu를 따로 수행한다.

img_ = img[y*bh:(y+1)*bh, x*bw:(x+1)*bw]
dst_ = dst2[y*bh:(y+1)*bh, x*bw:(x+1)*bw]

# img_.shape -> (bh, bw)
# dst_.shape -> (bh, bw)

이 방식은 직접 구현한 로컬 threshold의 가장 단순한 형태다.

cv2.threshold의 출력 버퍼 활용

이 코드의 특징은 결과를 새로 반환받는 대신, dst_라는 대상 배열에 직접 써 넣는 형태를 사용한다는 점이다. 이렇게 하면 블록 결과가 전체 결과 이미지 dst2의 해당 위치에 바로 반영된다.

cv2.threshold(img_, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU, dst_)

# 입력 블록  : img_ -> (bh, bw)
# 출력 위치  : dst_ -> (bh, bw)
# 최종 반영  : dst2 내부 일부 영역이 갱신됨

cv2.adaptiveThreshold

블록마다 임계값을 직접 계산해 주는 고수준 함수다.
블록 단위 수동 분할보다 더 일반적이고 실무에서 자주 쓰인다.

dst3 = cv2.adaptiveThreshold(
    img, 255,
    cv2.ADAPTIVE_THRESH_MEAN_C,
    cv2.THRESH_BINARY,
    9, 6
)

인자의 의미는 다음과 같다.

  • 입력 영상: 그레이스케일 영상
  • 최대값: 보통 255
  • 적응 방식: 평균 기반 또는 가우시안 가중 평균 기반
  • 이진화 타입
  • 블록 크기: 홀수여야 함
  • 보정 상수 C: 계산된 임계값에서 빼는 값

ADAPTIVE_THRESH_MEAN_C

주변 영역 평균을 기준으로 임계값을 정한다.

ADAPTIVE_THRESH_GAUSSIAN_C

주변 영역을 평균내되, 중심 픽셀에 더 큰 가중치를 준다. 조명 변화가 매끄럽게 분포하는 경우 더 자연스럽게 동작하는 편이다.

입력과 출력의 shape 변화

img.shape
# (H, W)

bw = img.shape[1] // 4
bh = img.shape[0] // 4
# 각 블록 shape -> (bh, bw)

img_.shape
# (bh, bw)

dst_.shape
# (bh, bw)

dst2.shape
# (H, W)

dst3.shape
# (H, W)

dst4.shape
# (H, W)

오해하기 쉬운 포인트

적응형 이진화는 결과 shape을 바꾸지 않는다.
지역별로 임계값이 달라질 뿐, 결과는 여전히 입력과 같은 크기의 이진 이미지다.

AI가 추천하는 심화예제

  • 그림자가 있는 문서 사진에 전역 Otsu와 adaptiveThreshold 비교
  • blockSize를 9, 15, 31로 바꿔 결과 비교
  • C 값을 0, 5, 10으로 바꾸어 문자 보존 정도 확인
  • morphology 연산과 결합해 노이즈 제거까지 연결

5. 아핀 변환은 무엇을 보존하고 무엇을 바꾸는가

아핀 변환은 평행성을 유지하는 선형 변환 계열이다. 이동, 회전, 확대·축소, 전단을 모두 포함한다. 직선은 직선으로 남고, 서로 평행한 선도 평행을 유지한다. 하지만 원근감은 표현하지 못하므로, 카메라 각도로 인해 생긴 사다리꼴 왜곡은 아핀 변환만으로 완전히 펴지지 않는다.

왜 필요한가

이미지 편집과 전처리의 대부분은 위치와 크기를 바꾸는 일이다.

  • 물체를 일정 위치로 이동
  • 입력 크기를 모델 입력 크기에 맞춤
  • 회전된 영상을 정렬
  • 데이터를 증강해 학습 다양성 확보

이 단계에서 가장 자주 만나게 되는 함수가 warpAffine, resize, getRotationMatrix2D다.

어떻게 구현되는가

import cv2
import numpy as np

img = cv2.imread('./images/dog.bmp')

aff = np.array([
    [1, 0, 150],
    [0, 1, 100]
], dtype=np.float32)

dst1 = cv2.warpAffine(img, aff, (0, 0))

dst2 = cv2.resize(img, (1280, 1024), interpolation=cv2.INTER_NEAREST)
dst3 = cv2.resize(img, (1280, 1024), interpolation=cv2.INTER_LINEAR)
dst4 = cv2.resize(img, (1280, 1024), interpolation=cv2.INTER_CUBIC)

cp = (img.shape[1] // 2, img.shape[0] // 2)
rot = cv2.getRotationMatrix2D(cp, 30, 0.7)
dst5 = cv2.warpAffine(img, rot, (0, 0))

# img.shape  -> (H, W, 3)
# aff.shape  -> (2, 3)
# rot.shape  -> (2, 3)
# dst1.shape -> 원본과 동일 또는 자동 계산된 크기
# dst2.shape -> (1024, 1280, 3)
# dst3.shape -> (1024, 1280, 3)
# dst4.shape -> (1024, 1280, 3)

주요 메서드와 함수의 역할

cv2.warpAffine

2×3 아핀 행렬을 이용해 입력 영상을 새 좌표계로 보낸다.

dst = cv2.warpAffine(src, M, dsize)

# src   : 입력 영상, (H, W) 또는 (H, W, C)
# M     : 2x3 변환 행렬
# dsize : 출력 크기 (width, height)
# dst   : 변환된 영상

이동 행렬은 다음과 같이 생긴다.

[
M=
\begin{bmatrix}
1 & 0 & t_x
0 & 1 & t_y
\end{bmatrix}
]

여기서 $t_x$, $t_y$는 각각 x축, y축 이동량이다.

aff = np.array([
    [1, 0, 150],
    [0, 1, 100]
], dtype=np.float32)

# aff.shape -> (2, 3)

cv2.resize

출력 크기를 명시해 리사이즈한다.
딥러닝 입력 전처리, 썸네일 생성, 해상도 표준화에 매우 자주 쓰인다.

dst = cv2.resize(img, (1280, 1024), interpolation=cv2.INTER_LINEAR)

# 입력  : img -> (H, W, 3)
# 출력  : dst -> (1024, 1280, 3)
# 주의  : dsize는 (width, height) 순서

보간법의 차이

  • INTER_NEAREST: 가장 가까운 픽셀 복사. 빠르지만 계단 현상이 크다.
  • INTER_LINEAR: 양선형 보간. 기본값이며 대부분의 일반 용도에 적절하다.
  • INTER_CUBIC: 더 넓은 주변 픽셀을 사용해 부드럽지만 느리다.

cv2.getRotationMatrix2D

회전 중심, 각도, 배율을 받아 2×3 회전 행렬을 만든다.

cp = (img.shape[1] // 2, img.shape[0] // 2)
rot = cv2.getRotationMatrix2D(cp, 30, 0.7)

# cp        : 회전 중심 (x, y)
# 30        : 회전 각도
# 0.7       : 스케일
# rot.shape : (2, 3)

이 행렬도 결국 warpAffine에 그대로 들어간다.

입력과 출력의 shape 변화

img.shape
# (H, W, 3)

aff.shape
# (2, 3)

dst1 = cv2.warpAffine(img, aff, (0, 0))
# 보통 원본 크기 유지 형태로 처리됨
# dst1.shape -> (H, W, 3) 또는 내부 계산 결과

dst2 = cv2.resize(img, (1280, 1024))
# dst2.shape -> (1024, 1280, 3)

rot.shape
# (2, 3)

dst5 = cv2.warpAffine(img, rot, (0, 0))
# dst5.shape -> 원본 기준 출력 크기

오해하기 쉬운 포인트

resize의 목표 크기는 (높이, 너비)가 아니라 (너비, 높이)다.
NumPy shape은 (높이, 너비, 채널)인데, OpenCV의 일부 함수 인자는 (너비, 높이)를 쓴다. 이 차이를 놓치면 결과 shape이 예상과 다르게 나온다.

AI가 추천하는 심화예제

  • 같은 이미지에 회전 후 잘리는 영역을 최소화하도록 캔버스 크기 재계산
  • resize 전에 aspect ratio 유지용 letterbox 추가
  • 아핀 행렬을 직접 구성해 전단 변환 실험
  • 데이터 증강 파이프라인으로 이동 + 회전 + 스케일 조합

6. 투시 변환은 왜 문서 보정에서 핵심인가

투시 변환은 카메라 원근으로 인해 기울어진 사각형을 정면에서 본 직사각형처럼 펴는 데 사용한다. 아핀 변환이 평행성은 유지하지만 원근은 복원하지 못하는 반면, 투시 변환은 4개의 대응점을 기준으로 원근 왜곡까지 교정한다. 문서 스캔, 명함 인식, 칠판 사진 보정에서 사실상 필수다.

왜 필요한가

카메라로 문서나 보드를 촬영하면 네 모서리가 직사각형이 아니라 사다리꼴처럼 보이기 쉽다. 이 상태에서는 OCR 정확도가 떨어지고, 측정이나 후속 전처리도 불안정해진다.
투시 변환은 "사진 속 좌표계"를 "정면 좌표계"로 다시 매핑하는 과정이다.

어떻게 구현되는가

import cv2
import numpy as np

img = cv2.imread('./images/pic.jpg')

srcQuad = np.array([
    [369, 172],
    [1220, 154],
    [1419, 830],
    [210, 847]
], dtype=np.float32)

dstQuad = np.array([
    [0, 0],
    [600, 0],
    [600, 500],
    [0, 500]
], dtype=np.float32)

pers = cv2.getPerspectiveTransform(srcQuad, dstQuad)
dst = cv2.warpPerspective(img, pers, (600, 500))

# srcQuad.shape -> (4, 2)
# dstQuad.shape -> (4, 2)
# pers.shape    -> (3, 3)
# img.shape     -> (H, W, 3)
# dst.shape     -> (500, 600, 3)

주요 메서드와 함수의 역할

cv2.getPerspectiveTransform

입력 사각형과 출력 사각형의 네 점 대응 관계를 받아 3×3 호모그래피 행렬을 계산한다.

pers = cv2.getPerspectiveTransform(srcQuad, dstQuad)

# srcQuad : 원본 영상의 네 점, shape (4, 2)
# dstQuad : 목표 평면의 네 점, shape (4, 2)
# pers    : 3x3 변환 행렬

네 점의 순서는 매우 중요하다. 보통 좌상단, 우상단, 우하단, 좌하단 순서로 맞춘다. 순서가 틀리면 영상이 뒤틀리거나 뒤집힌다.

cv2.warpPerspective

3×3 투시 행렬을 적용해 영상을 새 평면으로 투영한다.

dst = cv2.warpPerspective(img, pers, (600, 500))

# 입력  : img -> (H, W, 3)
# 행렬  : pers -> (3, 3)
# 출력  : dst  -> (500, 600, 3)

아핀 변환과의 가장 큰 차이는 행렬 크기다.

  • 아핀 변환: 2×3
  • 투시 변환: 3×3

입력과 출력의 shape 변화

srcQuad.shape
# (4, 2)

dstQuad.shape
# (4, 2)

pers.shape
# (3, 3)

dst.shape
# (500, 600, 3)

오해하기 쉬운 포인트

투시 변환은 단순히 이미지를 잘라내는 것이 아니라, 좌표계를 다시 정의하는 작업이다.
따라서 결과 이미지의 shape은 입력과 다를 수 있으며, dsize로 최종 평면 크기를 직접 정해야 한다.

AI가 추천하는 심화예제

  • 자동 모서리 검출과 결합해 문서 스캐너 만들기
  • A4 비율을 유지하며 문서 크기 정규화
  • 투시 보정 후 adaptiveThreshold를 적용해 OCR 입력 생성
  • 잘못된 점 순서를 자동 정렬하는 함수 추가

7. 명함 보정에서 마우스 드래그 기반 코너 조정이 필요한 이유

실제 문서 사진은 자동 검출만으로 정확한 꼭짓점을 잡지 못하는 경우가 많다. 명함처럼 경계가 분명해 보여도 배경, 그림자, 반사광, 색상 유사성 때문에 네 점이 어긋날 수 있다. 그래서 수동으로 코너를 조정하는 인터랙션이 필요하다. 이 구조는 단순 편집 도구가 아니라, 투시 변환의 입력 정확도를 높이는 전처리 인터페이스다.

왜 필요한가

투시 변환은 네 점이 정확할수록 결과가 좋아진다.
입력 점이 조금만 어긋나도 결과는 크게 찌그러질 수 있다. 특히 명함처럼 작은 문서는 꼭짓점 오차가 결과 전체 왜곡으로 이어진다.

따라서 다음 두 단계가 중요하다.

  1. 현재 선택된 네 점을 시각적으로 보여주기
  2. 사용자가 직접 각 점을 끌어 수정하기

어떻게 구현되는가

두 파일은 같은 목적을 서로 다른 방식으로 구현한다.

  • 첫 번째 방식은 NumPy 배열로 꼭짓점을 관리한다.
  • 두 번째 방식은 원 객체와 선분 정보를 따로 관리한다.

방식 1: 꼭짓점 배열 기반

srcQuad = np.array([
    [30, 30],
    [30, h-30],
    [w-30, h-30],
    [w-30, 30]
], dtype=np.float32)

dragSrc = [False, False, False, False]

def drawROI(img, corners):
    cpy = img.copy()
    for pt in corners:
        cv2.circle(cpy, tuple(pt.astype(int)), 25, (192,192,255), -1)
    cv2.line(cpy, tuple(corners[0].astype(int)), tuple(corners[1].astype(int)), (128,128,255), 2)
    cv2.line(cpy, tuple(corners[1].astype(int)), tuple(corners[2].astype(int)), (128,128,255), 2)
    cv2.line(cpy, tuple(corners[2].astype(int)), tuple(corners[3].astype(int)), (128,128,255), 2)
    cv2.line(cpy, tuple(corners[3].astype(int)), tuple(corners[0].astype(int)), (128,128,255), 2)
    return cpy

# srcQuad.shape -> (4, 2)
# drawROI 출력 shape -> img.shape와 동일

방식 2: 원 객체와 선분 재구성 기반

circles = [
    {'center': (50, 50), 'radius': 20},
    {'center': (500, 50), 'radius': 20},
    {'center': (500, 800), 'radius': 20},
    {'center': (50, 800), 'radius': 20}
]

def set_lines():
    global lines
    lines = [
        [circles[0]['center'], circles[1]['center']],
        [circles[1]['center'], circles[2]['center']],
        [circles[2]['center'], circles[3]['center']],
        [circles[3]['center'], circles[0]['center']]
    ]

첫 번째 방식은 투시 변환 입력과 직접 연결되기 쉬운 구조고, 두 번째 방식은 객체 단위 상태 관리가 더 직관적이다.

주요 메서드와 함수의 역할

drawROI

현재 코너 상태를 시각적으로 그리는 렌더링 함수다. 입력 이미지를 직접 수정하지 않고 복사본을 만들어 표시용 화면을 반환한다.

def drawROI(img, corners):
    cpy = img.copy()
    ...
    return cpy

# 입력  : img -> (H, W, 3), corners -> (4, 2)
# 출력  : cpy -> (H, W, 3)

cv2.norm

마우스 위치가 특정 코너 원 내부에 있는지 판단하는 데 사용한다.

if cv2.norm(srcQuad[i] - (x, y)) < 25:
    dragSrc[i] = True

유클리드 거리 기반으로 클릭 위치와 코너 간 거리를 구한다. 반지름 25 이내라면 해당 코너를 잡은 것으로 본다.

onMouse / mouse_handler

드래그되는 점의 상태를 갱신한다.

if event == cv2.EVENT_MOUSEMOVE:
    for i in range(4):
        if dragSrc[i]:
            srcQuad[i] = (x, y)
            cpy = drawROI(img, srcQuad)
            cv2.imshow('img', cpy)
            break

혹은 객체 기반으로는 다음처럼 중심점을 직접 바꾼다.

if IsDrag and selected_circle is not None:
    selected_circle['center'] = (x, y)
    set_lines()
    redraw_circles()
    cv2.imshow('img', img)

cv2.getPerspectiveTransform + cv2.warpPerspective

마우스로 조정한 네 점이 최종적으로 들어가는 곳이다. 인터랙션의 목적은 결국 이 두 함수에 정확한 입력을 공급하는 것이다.

dstQuad = np.array([
    [0, 0],
    [0, dh],
    [dw, dh],
    [dw, 0]
], dtype=np.float32)

pers = cv2.getPerspectiveTransform(srcQuad, dstQuad)
dst = cv2.warpPerspective(img, pers, (dw, dh), flags=cv2.INTER_CUBIC)

# srcQuad.shape -> (4, 2)
# dstQuad.shape -> (4, 2)
# pers.shape    -> (3, 3)
# dst.shape     -> (dh, dw, 3)

입력과 출력의 shape 변화

img.shape
# (H, W, 3)

srcQuad.shape
# (4, 2)

dstQuad.shape
# (4, 2)

pers.shape
# (3, 3)

dst.shape
# (dh, dw, 3)
# 여기서 dh = 500
# dw = round(dh * 297 / 210)
# 즉 A4 비율을 유지한 출력 크기

설계 포인트

명함 보정 예제에서 중요한 설계는 출력 평면 비율을 A4에 맞추는 부분이다.

dh = 500
dw = round(dh * 297 / 210)

이 값은 임의 숫자가 아니라 실제 종이 비율을 반영한다.
즉 단순히 "펴기"만 하는 것이 아니라, 결과 문서가 사람이 다루기 쉬운 표준 비율을 가지도록 의도한 설계다.

오해하기 쉬운 포인트

두 번째 명함 예제는 반복문 안에서 waitKey를 두 번 호출하는 구조라 입력 처리 흐름이 다소 불안정할 수 있다. 인터랙션 도구를 만들 때는 이벤트 루프 안에서 키 입력을 한 번만 읽고 분기하는 방식이 더 일반적이다. 하지만 여기서 핵심은 드래그 가능한 제어점과 투시 변환의 연결 구조다.

AI가 추천하는 심화예제

  • 점 4개를 자동 정렬해 좌상단, 우상단, 우하단, 좌하단 순서 보정
  • 드래그 중 확대 미리보기 창 제공
  • 코너 스냅 기능을 넣어 엣지 검출 결과에 자동 흡착
  • 보정 완료 후 그레이스케일, 이진화, OCR까지 한 번에 연결

8. 주요 함수만 따로 묶어 정리

이번 파일군은 모델 학습 코드가 아니라 영상 조작 함수 중심이므로, 실제로 이해해야 할 대상은 클래스보다 함수다. 아래 함수들은 이후 OpenCV 실전에서도 반복해서 다시 만나게 된다.

cv2.threshold

ret, dst = cv2.threshold(src, thresh, maxval, type)

# src.shape -> (H, W)
# ret       -> scalar
# dst.shape -> (H, W)

역할은 픽셀 단위 이진 분류다.
입력은 단일 채널 영상, 출력은 같은 shape의 이진 이미지다.

cv2.adaptiveThreshold

dst = cv2.adaptiveThreshold(src, 255, method, type, blockSize, C)

# src.shape -> (H, W)
# dst.shape -> (H, W)

역할은 지역 통계를 기반으로 픽셀별 임계값을 정하는 것이다.
조명 변화가 있을 때 전역 threshold보다 안정적이다.

cv2.warpAffine

dst = cv2.warpAffine(src, M, dsize)

# src.shape -> (H, W, C) 또는 (H, W)
# M.shape   -> (2, 3)
# dst.shape -> 지정된 dsize에 따른 결과

역할은 이동, 회전, 스케일, 전단 같은 아핀 변환이다.

cv2.getRotationMatrix2D

M = cv2.getRotationMatrix2D(center, angle, scale)

# center -> (x, y)
# angle  -> scalar
# scale  -> scalar
# M.shape -> (2, 3)

역할은 회전용 아핀 행렬을 생성하는 것이다.
실제 변환은 warpAffine이 수행한다.

cv2.getPerspectiveTransform

H = cv2.getPerspectiveTransform(srcQuad, dstQuad)

# srcQuad.shape -> (4, 2)
# dstQuad.shape -> (4, 2)
# H.shape       -> (3, 3)

역할은 네 점 대응으로부터 원근 보정 행렬을 계산하는 것이다.

cv2.warpPerspective

dst = cv2.warpPerspective(src, H, dsize)

# src.shape -> (H, W, C) 또는 (H, W)
# H.shape   -> (3, 3)
# dst.shape -> 지정된 출력 크기

역할은 투시 변환 적용이다.
문서 보정, 명함 보정, 보드 인식 같은 작업에서 핵심이다.

cv2.setMouseCallback

cv2.setMouseCallback(window_name, callback)

# 입력  : 창 이름, 콜백 함수
# 출력  : 없음

역할은 GUI 창과 사용자 입력을 연결하는 것이다.
이 함수 하나로 선택 도구, 드래그 도구, 라벨링 인터페이스의 출발점이 만들어진다.


9. 이 파일들을 수정하거나 확장하려면 어디를 먼저 이해해야 하는가

첫 번째로 이해해야 하는 것은 좌표계다.
배열 인덱싱은 [y, x], 그리기 함수는 (x, y), 출력 크기 인자는 종종 (width, height)다. OpenCV 작업에서 발생하는 대부분의 실수는 이 세 가지 표현이 뒤섞이면서 생긴다.

두 번째는 결과 버퍼가 새로 만들어지는지, 원본을 공유하는지다.
ROI는 슬라이싱만 하면 원본과 연결될 수 있고, 마우스 드래그 시에는 img.copy가 없으면 그림이 누적된다.

세 번째는 어떤 변환이 어떤 행렬을 요구하는지다.

  • 이동, 회전, 스케일: 2×3 아핀 행렬
  • 원근 보정: 3×3 투시 행렬

네 번째는 인터랙션과 후처리의 연결이다.
마우스로 좌표를 잡는 부분은 단독 기능이 아니라, threshold나 warpPerspective의 입력을 정확하게 만들기 위한 전단계다. 이 연결을 이해하면 단순 예제를 실제 도구로 확장할 수 있다.


10. 정리

이번 정리의 핵심은 영상 처리 함수 몇 개를 따로 외우는 것이 아니라, 각 함수가 좌표와 배열 shape를 어떻게 다루는지 구조적으로 이해하는 데 있다. ROI는 부분 선택, threshold는 픽셀 분류, affine은 평행성 유지 변환, perspective는 원근 보정으로 정리할 수 있다. 마우스 이벤트 처리는 이 모든 기능을 사람이 직접 제어할 수 있게 만드는 인터페이스 계층이다. 결국 이 파일군은 이미지 처리 입문 예제처럼 보이지만, 실제로는 문서 스캐너, 명함 정리기, OCR 전처리기, 라벨링 도구로 확장되는 핵심 구성요소를 이미 모두 포함하고 있다.