경계 검출
Sobel과 Canny 경계 검출 적용
실습 준비
import cv2 as cv
import numpy as np
from yeom import to_pil
이미지 불러오기
src = cv.imread("temple.webp")
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
Sobel 경계 검출
Sobel 연산자: 주변 픽셀의 밝기 차이로 x·y 방향의 기울기(gradient) 계산. 미분 방향으로 밝기 차이를 구하고 수직 방향으로 [1, 2, 1] 가중 평균을 적용해 작은 잡음의 영향 감소.
Sobel 커널:
sobel_x: 좌우 밝기 변화가 큰 수직 경계에 강하게 반응sobel_y: 상하 밝기 변화가 큰 수평 경계에 강하게 반응- 응답의 부호: 어두운 영역에서 밝은 영역으로 변하는 방향과 그 반대 방향 구분
sobel_x = cv.Sobel(gray, cv.CV_32F, 1, 0, ksize=3) # 3×3 x 방향 1차 미분
sobel_y = cv.Sobel(gray, cv.CV_32F, 0, 1, ksize=3) # 3×3 y 방향 1차 미분
sobel_magnitude = cv.magnitude(sobel_x, sobel_y)
float(sobel_magnitude.max()) # 경계 강도 최댓값
1104.171142578125
경계 강도의 최댓값은 8비트 범위인 255를 초과. cv.CV_32F는 음수와 255보다 큰 기울기를 보존.
각 픽셀의 두 기울기를 결합해 경계 강도 와 기울기 방향 계산.
가 클수록 밝기가 급격하게 변하는 위치. 는 밝기가 가장 빠르게 증가하는 기울기 방향이며 실제 경계선의 진행 방향과 수직. Canny는 이 방향을 이용해 경계선을 얇게 정리.
화면 표시용 x·y 응답은 절댓값의 8비트 이미지로 변환하고 결합 결과는 0~255로 정규화. 이 과정에서 원래 기울기의 부호와 크기 범위는 변화.
sobel_x_view = cv.convertScaleAbs(sobel_x) # 절댓값을 8비트로 표시
sobel_y_view = cv.convertScaleAbs(sobel_y)
sobel_result = cv.normalize(
sobel_magnitude,
None,
0,
255,
cv.NORM_MINMAX, # 최솟값을 0, 최댓값을 255로 변환
cv.CV_8U,
)
to_pil(cv.hconcat([gray, sobel_x_view, sobel_y_view, sobel_result]))

기둥의 세로선은 Sobel X에서, 지붕과 돌의 가로선은 Sobel Y에서 상대적으로 강한 반응. 결합 결과에는 두 방향의 경계가 모두 유지.
Sobel 결과는 경계 후보의 강도를 연속값으로 표현. 같은 경계가 두껍게 나타나거나 작은 잡음까지 검출될 가능성.
Canny 경계 검출
Canny 경계 검출: Sobel 기울기를 얇고 연결된 이진 경계로 정리하는 방법. 주요 처리 순서:
- 잡음 완화: 가우시안 필터로 작은 밝기 변화를 줄여 거짓 경계 억제
- 기울기 계산: Sobel 연산으로 경계 강도 와 기울기 방향 계산
- 비최대 억제: 기울기 방향의 양쪽 픽셀과 경계 강도를 비교하고 국소 최댓값이 아닌 응답 제거
- 이중 임계값: 경계 강도를 강한 경계, 약한 경계, 제거 대상의 세 범주로 분류
- 히스테리시스: 강한 경계에 이어진 약한 경계만 유지하고 고립된 약한 응답 제거
비최대 억제(non-maximum suppression): Sobel에서 띠처럼 두껍게 나온 응답의 중심만 남기는 단계. 기울기 방향이 좌우라면 현재 픽셀의 강도를 왼쪽·오른쪽 픽셀과 비교. 현재 값이 가장 크지 않으면 0으로 제거.
이중 임계값(double threshold): 비최대 억제 결과를 다음 세 범주로 분류.
히스테리시스(hysteresis): 강한 경계에 연결된 약한 경계만 실제 경계로 판단. 강한 경계와 이어진 약한 경계는 유지하고 고립된 약한 경계는 잡음으로 제거.
OpenCV의 cv.Canny(): 기울기 계산부터 히스테리시스까지 내부 수행. 이 실습에서는 0보다 큰 Sobel 경계 강도의 90번째 백분위수를 상한값으로 사용하고 그 40%를 하한값으로 설정.
blurred_gray = cv.GaussianBlur(gray, (5, 5), 0) # 5×5 커널로 잡음 완화
canny_sobel_x = cv.Sobel(blurred_gray, cv.CV_32F, 1, 0, ksize=3) # 3×3 x 방향 기울기
canny_sobel_y = cv.Sobel(blurred_gray, cv.CV_32F, 0, 1, ksize=3) # 3×3 y 방향 기울기
canny_magnitude = cv.magnitude(canny_sobel_x, canny_sobel_y)
positive_magnitude = canny_magnitude[canny_magnitude > 0]
upper = float(np.percentile(positive_magnitude, 90)) # 강한 상위 10%의 경계 선택
lower = upper * 0.4 # 연결할 약한 경계의 시작점
canny_edges = cv.Canny(
blurred_gray,
lower,
upper,
L2gradient=True, # 유클리드 거리로 경계 강도 계산
)
lower감소: 더 약한 경계까지 연결 후보로 포함하여 검출량 증가upper증가: 강한 경계로 시작할 수 있는 픽셀이 줄어 검출량 감소- 경계 강도:
L2gradient=True이므로 사용. 기본값False는 로 근사 - 반환값: 경계는 255, 나머지는 0인
uint8이진 이미지
lower # 하한 임계값
94.90205078125001
upper # 상한 임계값
237.255126953125
검출 위치를 확인하기 위해 원본 이미지의 경계 픽셀을 빨간색으로 표시.
edge_overlay = src.copy() # 원본을 그대로 두고 사본에 표시
edge_overlay[canny_edges > 0] = (0, 0, 255) # BGR 순서이므로 빨간색
edge_pixels = cv.countNonZero(canny_edges)
edge_pixels # 경계 픽셀 수
33416
edge_pixels / canny_edges.size * 100 # 경계 픽셀 비율(%)
9.13245004154095
canny_comparison = cv.hconcat(
[
cv.cvtColor(blurred_gray, cv.COLOR_GRAY2BGR),
cv.cvtColor(canny_edges, cv.COLOR_GRAY2BGR),
edge_overlay,
]
)
to_pil(canny_comparison)

temple.webp에서는 전체 픽셀의 약 9.13%가 경계로 선택. 신전의 외곽과 기둥처럼 이어진 구조는 유지되고 평탄한 하늘 영역은 대부분 제거.
Sobel과 Canny의 차이
Sobel은 경계의 강도와 방향 분석에 적합. Canny는 비최대 억제와 히스테리시스를 추가하여 객체 윤곽처럼 가늘고 연결된 이진 경계가 필요할 때 적합.