OpenCV 예제는 밝기 조절, 블러, 이진화, 외곽선 검출을 따로 배우기 쉽다. 하지만 실제 작업에서는 한 단계의 결과가 다음 단계의 입력이 된다. 영수증 사진에서 문서 영역을 찾는 예제로 이미지 로딩부터 결과 저장까지 한 흐름으로 연결해 보자.


1. 로딩 직후 shape와 dtype부터 확인한다
pip install opencv-python numpyfrom pathlib import Path
import cv2
import numpy as np
image_path = Path("receipt.jpg")
image = cv2.imread(str(image_path))
if image is None:
raise FileNotFoundError(f"이미지를 읽을 수 없습니다: {image_path}")
print(image.shape) # (높이, 너비, 채널)
print(image.dtype) # 보통 uint8cv2.imread()가 실패하면 예외 대신 None을 돌려준다. 따라서 경로와 파일 읽기 여부를 가장 먼저 검사해야 한다. 컬러 이미지는 일반적으로 (height, width, 3) 형태이며 픽셀 범위는 0~255다.
Matplotlib처럼 RGB를 기대하는 도구로 표시할 때는 cv2.cvtColor(image, cv2.COLOR_BGR2RGB)로 변환한다. PNG라고 항상 4채널인 것도 아니다. 알파 채널이 있는 파일을 그대로 읽으려면 cv2.IMREAD_UNCHANGED를 사용하고 실제 shape를 확인한다.
2. 디지털 이미지는 좌표와 밝기의 유한한 표다
카메라가 연속적인 장면을 디지털 이미지로 바꿀 때 공간을 격자로 나누는 과정이 샘플링, 각 픽셀의 밝기나 색을 유한한 단계로 표현하는 과정이 양자화다. 해상도를 줄이면 공간 샘플 수가 감소하고, 비트 수를 줄이면 표현 가능한 밝기 단계가 감소한다. 둘은 모두 “화질 저하”로 보일 수 있지만 원인과 해결 방법이 다르다.
| 개념 | 줄였을 때 보이는 현상 | 실전 확인 |
|---|---|---|
| 공간 샘플링 | 경계 계단, 작은 글자·얇은 선 소실 | 원본 해상도와 축소 보간법 기록 |
| 밝기 양자화 | 그라데이션이 띠처럼 끊기는 banding | dtype과 값 범위, 저장 포맷 확인 |
| 색상 채널 | 도구 사이에서 빨강·파랑 반전 | BGR·RGB·GRAY 변환 경로 기록 |
히스토그램은 임계값을 고르는 출발점이다
그레이스케일 히스토그램은 밝기별 픽셀 수를 보여 준다. 전경과 배경이 서로 다른 밝기 군집을 만들면 두 봉우리 사이에서 임계값 후보를 찾을 수 있다. 하지만 조명 그라데이션이나 그림자가 크면 한 개의 전역 임계값만으로 분리하기 어렵다.
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
hist = cv2.calcHist([gray], [0], None, [256], [0, 256])
print("가장 많은 밝기:", int(hist.argmax()))
equalized = cv2.equalizeHist(gray)명암을 넓히는 동시에 노이즈도 강조할 수 있다. 전체 영상보다 작은 구역별로 적용하는 CLAHE도 있지만, 전후 히스토그램과 다음 단계의 검출 결과를 함께 비교해야 한다.
3. 밝기와 크기는 자료형·보간법까지 함께 본다
밝기 조절
uint8 배열에 NumPy 덧셈을 바로 적용하면 255를 넘는 값이 순환할 수 있다. OpenCV의 cv2.add()는 범위를 0~255로 잘라 주므로 밝기 조절에 안전하다.
brighter = cv2.add(image, np.full_like(image, 35))
darker = cv2.subtract(image, np.full_like(image, 35))크기 조절
| 보간법 | 주요 용도 | 특징 |
|---|---|---|
INTER_AREA | 축소 | 영역 정보를 활용해 축소 시 깨짐이 적다. |
INTER_LINEAR | 일반적인 확대·축소 | 속도와 품질의 균형이 좋다. |
INTER_CUBIC | 품질을 중시한 확대 | 계산량은 늘지만 부드럽다. |
INTER_NEAREST | 마스크·라벨 이미지 | 새 중간값을 만들지 않아 클래스 번호를 보존한다. |
height, width = image.shape[:2]
small = cv2.resize(
image,
(width // 2, height // 2),
interpolation=cv2.INTER_AREA
)4. 필터는 무엇을 보존할지 정해서 고른다
노이즈를 줄이는 목적은 다음 단계의 이진화나 외곽선 검출을 안정시키는 것이다. 강한 블러가 항상 좋은 것은 아니다. 글자 경계까지 사라지면 검출 성능이 오히려 낮아진다.
| 필터 | 강점 | 적합한 상황 |
|---|---|---|
| Gaussian Blur | 전체 노이즈를 자연스럽게 완화 | 일반적인 전처리, 에지 검출 전 |
| Median Blur | 점 형태의 소금·후추 노이즈 제거 | 검고 흰 점이 튀는 이미지 |
| Bilateral Filter | 경계를 비교적 보존하면서 평탄화 | 경계 보존이 중요할 때, 다만 계산량이 큼 |
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
median = cv2.medianBlur(gray, 5)
bilateral = cv2.bilateralFilter(gray, 9, 75, 75)5. 기하 변환은 기준점과 출력 크기가 중요하다
회전은 이미지 중심을 기준으로 변환 행렬을 만들고, 결과 캔버스 크기를 지정한다. 원본과 같은 크기로 회전하면 모서리가 잘릴 수 있으므로 실제 작업에서는 여백이나 새 경계 크기를 계산해야 한다.
height, width = image.shape[:2]
center = (width / 2, height / 2)
matrix = cv2.getRotationMatrix2D(center, 10, 1.0)
rotated = cv2.warpAffine(
image,
matrix,
(width, height),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE
)6. 이진화 뒤에는 영역을 수치로 검증한다
이진화는 픽셀을 전경과 배경으로 나눈다. 조명이 일정하면 고정 임계값도 쓸 수 있지만, 촬영 환경이 달라지면 Otsu나 적응형 이진화가 유리하다. 임계값은 외워 넣는 숫자가 아니라 히스토그램과 결과 이미지를 보고 결정해야 한다.
# Otsu가 영상 분포를 보고 임계값을 선택
threshold_value, binary = cv2.threshold(
blurred,
0,
255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
)
print("선택된 임계값:", threshold_value)연결 요소와 외곽선의 역할
- 연결 요소 분석: 붙어 있는 전경 픽셀을 객체 단위로 번호 매기고 면적·중심·바운딩 박스를 얻는다.
- 외곽선 검출: 객체의 경계점을 얻어 둘레, 면적, 다각형 모양을 분석한다.
- 모폴로지 연산: 끊어진 글자나 선을 잇거나 작은 점 노이즈를 제거한다.
에지와 특징점은 목적이 다르다
Canny 에지는 밝기가 급격히 변하는 선을 찾는다. 외곽선은 이진 영상에서 연결된 경계점의 순서를 얻는다. Harris·Shi-Tomasi·ORB 같은 특징점은 회전이나 시점이 달라진 두 이미지의 대응 위치를 찾는 데 쓴다. 비슷해 보이지만 출력과 사용 목적이 다르므로 “윤곽을 찾는다”는 한 문장으로 섞지 않는다.
edges = cv2.Canny(blurred, 60, 160)
corners = cv2.goodFeaturesToTrack(
gray,
maxCorners=100,
qualityLevel=0.01,
minDistance=10
)
7. 문서 영역을 찾는 전체 코드
아래 코드는 명암 변환, 블러, Otsu 이진화, 닫힘 연산, 외곽선 검출을 거쳐 가장 큰 사각형 후보를 표시한다. 모든 사진에 그대로 통하는 만능 코드는 아니지만 각 단계의 역할을 확인하기 좋은 출발점이다.
from pathlib import Path
import cv2
import numpy as np
source = Path("receipt.jpg")
output = Path("receipt_detected.jpg")
image = cv2.imread(str(source))
if image is None:
raise FileNotFoundError(source)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
_, binary = cv2.threshold(
blurred, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7))
closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(
closed,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE
)
result = image.copy()
minimum_area = image.shape[0] * image.shape[1] * 0.10
candidates = [c for c in contours if cv2.contourArea(c) >= minimum_area]
if candidates:
largest = max(candidates, key=cv2.contourArea)
perimeter = cv2.arcLength(largest, True)
polygon = cv2.approxPolyDP(largest, 0.02 * perimeter, True)
if len(polygon) == 4:
cv2.polylines(result, [polygon], True, (0, 180, 0), 4)
else:
x, y, w, h = cv2.boundingRect(largest)
cv2.rectangle(result, (x, y), (x + w, y + h), (0, 180, 0), 4)
if not cv2.imwrite(str(output), result):
raise OSError(f"저장 실패: {output}")
print(f"결과 저장: {output.resolve()}")cv2.imshow()와 cv2.waitKey()는 GUI 환경이 필요하다. 서버, Docker, 일부 주피터 환경에서는 창이 열리지 않는다. 이때는 cv2.imwrite()로 저장하고 노트북에서는 Matplotlib으로 표시한다.
마우스·트랙바·그리기 함수
cv2.line(), rectangle(), circle()은 검출 결과를 설명하는 오버레이에 유용하다. 도형 함수의 thickness=-1은 내부를 채운다. 마우스 콜백과 트랙바는 임계값을 실시간 조정할 때 편하지만, 최종 파이프라인에는 선택된 값을 설정 파일이나 코드로 남겨 재현 가능하게 만든다.
8. 실수하기 쉬운 부분
imread()결과가None인지 확인하지 않고 다음 함수를 호출한다.- BGR 이미지를 RGB로 가정해 파란색과 빨간색이 바뀐다.
uint8에 NumPy 덧셈을 바로 적용해 값이 순환한다.- 마스크를 선형 보간해 0과 1 사이에 존재하지 않던 클래스 값이 생긴다.
- 고정 임계값 하나를 모든 조명과 카메라에 그대로 사용한다.
- 검출 면적 기준을 픽셀 고정값으로만 두어 해상도가 바뀌면 결과가 달라진다.
- 중간 결과를 저장하지 않아 어느 단계에서 실패했는지 찾기 어렵다.
- 이미지를 읽은 직후
None, shape, dtype, 채널 순서를 확인한다. - 필터와 임계값은 다음 단계에서 보존해야 할 경계를 기준으로 선택한다.
- 샘플링·양자화·색상 채널을 구분하고 히스토그램으로 밝기 분포를 확인한다.
- 이진화 뒤에는 면적, 연결 요소, 에지, 외곽선으로 결과를 수치화한다.
- 전체 파이프라인의 중간 이미지를 저장하면 오류 원인을 훨씬 빨리 찾을 수 있다.
'실전 개발 노트 > 개발 가이드' 카테고리의 다른 글
| 머신러닝 입문 실습: Orange부터 모델 평가·앙상블·군집화까지 (0) | 2026.07.29 |
|---|---|
| AI 개발 도구 고르는 법: 데이터·모델·실험·온디바이스 보안까지 (0) | 2026.07.28 |
| Idempotency-Key로 중복 주문을 막는 API 설계와 PostgreSQL 구현 (0) | 2026.07.22 |
| 2026 백엔드 개발자 로드맵: Java·Spring·DB·Docker 공부 순서 (0) | 2026.07.21 |
| GPT-5.6 Sol·Terra·Luna 선택 가이드: 어떤 모델이 내 작업에 맞을까? (0) | 2026.07.10 |