이미지 비교와 차영상
실습 준비
import cv2 as cv
import numpy as np
from yeom import to_pil
파일 불러오기
before = cv.imread("before.png")
after = cv.imread("after.png")
두 이미지를 픽셀별로 비교하려면 높이, 너비, 채널 수와 자료형이 모두 동일.
if before.shape != after.shape or before.dtype != after.dtype:
raise ValueError("두 이미지의 모양과 자료형이 다릅니다")
before.shape, before.dtype, after.shape, after.dtype
((420, 640, 3), dtype('uint8'), (420, 640, 3), dtype('uint8'))
실습 이미지에서는 같은 책상 장면을 유지하고 after.png에만 빨간 컵과 그림자를 추가.

이미지가 같은지 확인
np.array_equal()은 두 배열의 모양과 모든 원소가 같은지 확인하여 하나의 불리언 값을 반환.
np.array_equal(before, after)
False
== 연산자는 같은 위치의 픽셀값을 하나씩 비교하므로 이미지와 모양이 같은 불리언 배열을 생성.
pixel_comparison = before == after
pixel_comparison.shape, pixel_comparison.dtype
((420, 640, 3), dtype('bool'))
모든 위치가 같은지 확인하려면 all()을 적용.
pixel_comparison.all()
np.False_
두 실습 이미지는 일부 픽셀값이 다르므로 두 방법 모두 False를 반환.
두 방법으로 이미지의 완전한 일치 여부는 확인 가능하지만 차이의 크기와 위치는 확인 불가.
절대 차영상
차영상(difference image)은 같은 위치의 픽셀 차이를 영상으로 나타낸 결과. cv.absdiff()는 두 픽셀값의 차이를 절댓값으로 계산.
difference = cv.absdiff(before, after)
to_pil(difference)

- 검거나 어두운 영역: 동일한 픽셀값 또는 작은 차이
- 밝은 영역: 큰 픽셀값 차이
- 절댓값을 사용하므로 입력 순서와 무관하게 동일한 결과
reverse_difference = cv.absdiff(after, before)
np.array_equal(difference, reverse_difference)
True
입력 순서를 바꾼 차영상도 원래 차영상과 동일.
차영상을 그레이스케일로 변환
컬러 차영상은 BGR 채널마다 차이를 저장. 하나의 변화 마스크를 만들기 위해 그레이스케일로 변환.
gray_difference = cv.cvtColor(difference, cv.COLOR_BGR2GRAY)
to_pil(gray_difference)

gray_difference.min(), gray_difference.max()
(np.uint8(0), np.uint8(177))
그레이스케일 차이의 범위는 0–177.
변화 마스크
두 이미지에는 조명, 센서 잡음, 압축으로 인한 작은 픽셀 차이가 발생 가능. 문턱값 처리(thresholding)로 일정 크기보다 큰 차이만 선택.
difference_threshold = 30 # 작은 차이를 제외할 기준
_, change_mask = cv.threshold(
gray_difference,
difference_threshold,
255, # 선택된 픽셀을 흰색으로 표시
cv.THRESH_BINARY, # 문턱값보다 크면 255로 변환
)
to_pil(change_mask)

- 0: 차이가 문턱값 이하인 위치
- 255: 차이가 문턱값보다 큰 위치
문턱값이 낮으면 작은 변화까지 선택하고, 높으면 큰 변화만 선택.
threshold_values = [10, 30, 60] # 낮음, 중간, 높음 비교
change_masks = [
cv.threshold(gray_difference, value, 255, cv.THRESH_BINARY)[1]
for value in threshold_values
]
panels = []
for value, mask in zip(threshold_values, change_masks):
panel = cv.cvtColor(mask, cv.COLOR_GRAY2BGR)
cv.putText(
panel,
f"threshold={value}",
(18, 35), # 왼쪽 위 레이블 위치
cv.FONT_HERSHEY_SIMPLEX,
0.75, # 레이블 크기
(0, 0, 255), # BGR 빨간색
2, # 레이블 선 두께
cv.LINE_AA, # 레이블 경계를 부드럽게 표시
)
panels.append(panel)
threshold_comparison = cv.hconcat(panels)
to_pil(threshold_comparison)
왼쪽부터 문턱값 10, 30, 60의 결과.

변화 위치 표시
변화하지 않은 영역을 그레이스케일로 바꾸고 변화 마스크에 해당하는 영역만 원래 색으로 유지.
gray_after = cv.cvtColor(after, cv.COLOR_BGR2GRAY)
highlighted = cv.cvtColor(gray_after, cv.COLOR_GRAY2BGR)
highlighted[change_mask > 0] = after[change_mask > 0]
to_pil(highlighted)

변화량 수치화
cv.countNonZero()로 변화 마스크에서 0이 아닌 픽셀 수를 계산.
changed_pixels = cv.countNonZero(change_mask)
total_pixels = change_mask.size
change_ratio = changed_pixels / total_pixels
changed_pixels # 변화 픽셀 수
17618
total_pixels # 전체 픽셀 수
268800
change_ratio * 100 # 변화 비율(%)
6.554315476190477
문턱값 30에서 컵과 그림자가 차지하는 17,618픽셀, 전체의 6.55%가 변화로 선택. 변화 비율은 같은 장면을 반복 촬영하거나 제품의 전후 상태를 비교할 때 기준값으로 사용 가능.
변화 비율만으로 의미 판단 불가
변화 비율이 크다는 사실만으로 중요한 객체가 바뀌었다고 단정 불가. 조명 변화나 카메라 흔들림도 많은 픽셀 변화의 원인.
차영상의 한계
픽셀 위치를 직접 비교하므로 촬영 조건이 조금만 달라져도 차이가 발생.
- 카메라 이동: 물체의 모든 경계가 변화로 표시
- 조명 변화: 장면 전체 밝기 변화
- 자동 노출과 화이트 밸런스: 프레임별 색과 밝기 변화 가능
- 압축 잡음: 실제 장면이 같아도 작은 픽셀 차이 발생
연속 프레임 비교
동영상은 이미지 프레임의 연속이므로 이전 프레임과 현재 프레임의 차영상으로 움직임 확인 가능.
이전 프레임 ─┐
├─ absdiff → 문턱값 처리 → 움직임 마스크
현재 프레임 ─┘
웹캠의 움직임 감지
웹캠에서 프레임을 연속으로 받아 차영상을 계산. 카메라 인덱스 0은 기본 웹캠.
camera_index = 0 # 기본 웹캠
camera = cv.VideoCapture(camera_index)
if not camera.isOpened():
raise RuntimeError("웹캠을 열 수 없습니다")
ret, previous_frame = camera.read()
if not ret:
camera.release()
raise RuntimeError("웹캠 프레임을 읽지 못했습니다")
previous_gray = cv.cvtColor(previous_frame, cv.COLOR_BGR2GRAY)
현재 프레임과 이전 프레임을 비교하고 전체 픽셀의 1% 이상이 달라지면 움직임을 표시.
실행 환경
cv.imshow()와 웹캠은 데스크톱 GUI와 카메라 권한이 필요. 원격 서버나 일반적인 Colab 환경에서는 동일 방식으로 실행 곤란.
motion_threshold = 30 # 작은 프레임 차이를 제외할 기준
minimum_motion_ratio = 0.01 # 전체 픽셀의 1% 이상이면 움직임
text_position = (20, 40) # 왼쪽 위 상태 레이블 위치
font_scale = 1 # 상태 레이블 크기
text_thickness = 2 # 상태 레이블 선 두께
try:
while True:
ret, frame = camera.read()
if not ret:
break
gray = cv.cvtColor(frame, cv.COLOR_BGR2GRAY)
frame_difference = cv.absdiff(previous_gray, gray)
_, motion_mask = cv.threshold(
frame_difference,
motion_threshold,
255, # 선택된 픽셀을 흰색으로 표시
cv.THRESH_BINARY, # 문턱값보다 크면 255로 변환
)
motion_ratio = cv.countNonZero(motion_mask) / motion_mask.size
motion_detected = motion_ratio >= minimum_motion_ratio
label = "Motion" if motion_detected else "Still"
color = (0, 0, 255) if motion_detected else (0, 255, 0)
cv.putText(
frame,
label,
text_position,
cv.FONT_HERSHEY_SIMPLEX,
font_scale,
color,
text_thickness,
cv.LINE_AA, # 글자 경계를 부드럽게 표시
)
cv.imshow("frame", frame)
cv.imshow("motion mask", motion_mask)
previous_gray = gray
key = cv.waitKey(1) & 0xFF # 1ms 대기 후 하위 8비트 키 코드 확인
if key == ord("q"):
break
finally:
camera.release()
cv.destroyAllWindows()
previous_gray = gray로 기준 프레임을 계속 갱신하므로 연속한 두 웹캠 프레임 사이의 움직임을 감지. q 키를 누르면 실행 종료.