15장. 비디오 처리와 실시간 영상처리
프로젝트 1 — 주차장 입구 동영상 요약 사진 만들기
주차장 관리 수업을 준비하는 강사가 입구 카메라 동영상(가상) 15초 분량을 받았습니다. 수업 자료에는 동영상 대신 "자동차가 몇 대 지나갔고, 각각 어떻게 생겼는지"를 한 장으로 보여 주고 싶습니다. 움직임이 있는 구간만 골라 지나간 자동차 수를 세고, 구간마다 대표 프레임에 상자를 그린 요약 사진을 만들어 보세요.
ch15_parking.mp4 는 OpenCV 로 도형을 그려 만든 가상 동영상입니다. 데이터를 만들 때 자동차 세 대를 지나가게 했으므로 정답은 3대입니다. 중간에 작은 낙엽 하나가 잠깐 날립니다(자동차가 아닙니다).
| 항목 | 값 |
|---|---|
| 파일 | ch15_parking.mp4 |
| 크기와 FPS | 240×160, 10fps |
| 프레임 수 | 150장(15초) |
| 내용 | 고정된 입구 앞을 자동차(흰색, 남색, 노랑)가 왼쪽에서 오른쪽으로 하나씩 지나간다 |
| 정답 | 자동차 3대 |
요구사항
- 동영상의 프레임 수, FPS, 크기를 찍습니다.
- 프레임마다 "움직인 칸 비율"을 구합니다. 배경 빼기(중앙값 배경)를 씁니다.
- 비율이 기준보다 큰 프레임이 이어진 구간을 찾아, 구간 수를 지나간 자동차 수로 셉니다. 낙엽처럼 작은 움직임은 세지 않습니다.
- 센 수가 정답(3대)과 맞는지 확인해 찍습니다.
- 구간마다 움직인 칸 비율이 가장 큰 프레임을 대표로 골라 상자를 그리고, 대표 프레임들을 가로로 이어 붙인 요약 사진을
out_ch15_summary.png로 저장합니다. - 프레임마다의 비율을 그래프로 그리고, 찾은 구간과 기준선을 함께 표시합니다.
힌트
- 중앙값 배경은 실습 4처럼
np.median(np.stack(frames), axis=0)로 만듭니다. 자동차가 잠깐씩만 지나가므로 배경에서 지워집니다. - 움직인 칸 비율은
(mask > 0).mean()입니다. 잡음을 줄이려면 이진화 뒤 3×3 열기를 겁니다. - 구간 찾기: 프레임을 차례로 보며 "기준보다 큰가"가 False → True 로 바뀌는 곳이 구간의 시작, True → False 로 바뀌는 곳이 끝입니다.
- 기준을 0 으로 두면 낙엽도 구간이 됩니다. 기준을 바꿔 가며 구간 수가 어떻게 바뀌는지 보세요.
- 상자는 마스크에서 넓이가 가장 큰 윤곽선(12장)의
cv2.boundingRect로 그립니다.max(contours, key=cv2.contourArea)를 쓰면 됩니다. cv2.putText는 한글을 그리지 못합니다. 요약 사진의 글은CAR 1처럼 영문과 숫자로 적습니다.
채점 기준
| 항목 | 기준 | 점수 |
|---|---|---|
| 동영상 정보 | 프레임 수, FPS, 크기를 맞게 찍는다 | 10 |
| 움직임 비율 | 배경 빼기로 프레임마다 비율을 구한다 | 20 |
| 구간과 대수 | 작은 움직임을 빼고 구간을 찾아 대수를 센다 | 25 |
| 정답 확인 | 센 대수와 정답을 견줘 찍는다 | 10 |
| 요약 사진 | 구간마다 대표 프레임에 상자를 그려 이어 붙여 저장한다 | 20 |
| 그래프 | 비율, 기준선, 구간을 한 그래프에 그린다 | 15 |
코드의 흐름
그림의 다섯 단계가 예시 풀이의 단계와 같은 순서입니다. 셋째 단계의 기준값이 낙엽 같은 작은 움직임을 걸러 내는 곳이고, 넷째 단계에서 구간마다 한 장씩만 골라 동영상 150장을 사진 세 장으로 줄입니다.
실제 데이터로 해 보려면
실제 도로나 주차장 동영상은 AI Hub(aihub.or.kr) 같은 곳에 공개된 교통·차량 영상 데이터를 내려받아 같은 방법으로 시험할 수 있습니다. 내려받기 전에 데이터마다 이용 약관(상업적 이용 가능 여부, 재배포 금지 등)을 확인하고, 사람의 얼굴과 차량 번호판이 찍혀 있으면 개인정보 처리 기준을 먼저 확인합니다. 직접 찍을 때는 찍는다는 것을 알리고, 번호판이 보이지 않게 비추거나 가립니다. 실제 영상은 조명이 바뀌고 그림자와 나뭇잎이 흔들리므로, 중앙값 배경 대신 MOG2 를 쓰고 기준값과 넓이 기준을 다시 맞춰야 합니다.
결과 보기 (예시 풀이)
1단계. 동영상 읽고 정보 찍기
import cv2
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams["font.family"] = "Malgun Gothic"
plt.rcParams["axes.unicode_minus"] = False
ANSWER = 3 # 데이터를 만들 때 지나가게 한 자동차 수(정답)
# 요구사항 1: 동영상 정보
cap = cv2.VideoCapture("ch15_parking.mp4")
fps = cap.get(cv2.CAP_PROP_FPS)
print("프레임 수:", int(cap.get(cv2.CAP_PROP_FRAME_COUNT)), "· FPS:", fps,
"· 크기:", int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), "×", int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))
frames = []
while True:
ok, frame = cap.read()
if not ok:
break
frames.append(frame)
cap.release()
print("읽은 프레임:", len(frames), "장 →", len(frames) / fps, "초")프레임 수: 150 · FPS: 10.0 · 크기: 240 × 160
읽은 프레임: 150 장 → 15.0 초2단계. 배경 빼기로 움직인 칸 비율 구하기
# 요구사항 2: 150장의 중앙값으로 배경을 만든다. 자동차는 잠깐씩만 지나가 지워진다
background = cv2.cvtColor(np.median(np.stack(frames), axis=0).astype(np.uint8), cv2.COLOR_BGR2GRAY)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
def motion_mask(frame):
"""배경과 30 넘게 다른 칸을 흰색으로 한 마스크(3×3 열기로 잡음 제거)."""
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
_, mask = cv2.threshold(cv2.absdiff(gray, background), 30, 255, cv2.THRESH_BINARY)
return cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
ratios = [float((motion_mask(f) > 0).mean()) for f in frames]
print("움직임이 조금이라도 있는 프레임:", sum(r > 0 for r in ratios), "장")
print("가장 큰 비율:", round(max(ratios), 4), "· 프레임", int(np.argmax(ratios)))움직임이 조금이라도 있는 프레임: 87 장
가장 큰 비율: 0.0821 · 프레임 633단계. 움직임 구간을 찾아 대수 세기
def find_segments(ratios, min_ratio, min_len=3):
"""비율이 min_ratio 보다 큰 프레임이 min_len 장 넘게 이어진 구간 [(시작, 끝), ...] 을 돌려준다."""
segments, start = [], None
for i, r in enumerate(ratios + [0.0]): # 끝에 0 을 붙여 마지막 구간도 닫히게 한다
if r > min_ratio and start is None: # False → True: 구간 시작
start = i
elif r <= min_ratio and start is not None: # True → False: 구간 끝
if i - start >= min_len:
segments.append((start, i - 1))
start = None
return segments
# 요구사항 3: 기준을 바꿔 가며 구간 수를 견준다
for min_ratio in (0.0, 0.005, 0.01):
segs = find_segments(ratios, min_ratio)
print(f"기준 {min_ratio}: 구간 {len(segs)}개 {segs}")
MIN_RATIO = 0.005
segments = find_segments(ratios, MIN_RATIO)
# 요구사항 4: 정답과 견준다
print(f"센 자동차: {len(segments)}대 · 정답: {ANSWER}대 · 맞았나: {len(segments) == ANSWER}")기준 0.0: 구간 4개 [(9, 33), (40, 49), (56, 86), (106, 126)]
기준 0.005: 구간 3개 [(9, 33), (56, 86), (106, 126)]
기준 0.01: 구간 3개 [(9, 32), (57, 85), (106, 125)]
센 자동차: 3대 · 정답: 3대 · 맞았나: True4단계. 대표 프레임에 상자를 그려 요약 사진 저장하기
tiles = []
for k, (s, e) in enumerate(segments, start=1):
best = max(range(s, e + 1), key=lambda i: ratios[i]) # 구간 안에서 비율이 가장 큰 프레임
mask = motion_mask(frames[best])
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
x, y, w, h = cv2.boundingRect(max(contours, key=cv2.contourArea)) # 가장 큰 덩어리의 상자
tile = frames[best].copy()
cv2.rectangle(tile, (x, y), (x + w, y + h), (38, 93, 254), 2) # 다홍(BGR) 상자
# putText 는 한글을 못 그리므로 영문과 숫자로 적는다
cv2.putText(tile, f"CAR {k} #{best} {best / fps:.1f}s", (6, 16),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (20, 20, 20), 1, cv2.LINE_AA)
tiles.append(tile)
print(f"자동차 {k}: 프레임 {s}~{e} ({(e - s + 1) / fps:.1f}초) · 대표 {best}번 · 상자 {(x, y, w, h)}")
# 요구사항 5: 높이가 같은 대표 프레임을 가로로 이어 붙여 저장한다
summary = cv2.hconcat(tiles)
print("요약 사진 모양:", summary.shape, "· 저장:", cv2.imwrite("out_ch15_summary.png", summary))
plt.figure(figsize=(12, 3))
plt.imshow(cv2.cvtColor(summary, cv2.COLOR_BGR2RGB))
plt.title(f"주차장 입구 요약: 자동차 {len(segments)}대")
plt.axis("off")
plt.show()자동차 1: 프레임 9~33 (2.5초) · 대표 26번 · 상자 (146, 92, 71, 53)
자동차 2: 프레임 56~86 (3.1초) · 대표 63번 · 상자 (0, 92, 81, 53)
자동차 3: 프레임 106~126 (2.1초) · 대표 120번 · 상자 (146, 92, 65, 53)
요약 사진 모양: (160, 720, 3) · 저장: True
5단계. 비율 그래프에 구간 표시하기
# 요구사항 6: 비율, 기준선, 찾은 구간을 한 그래프에
fig, ax = plt.subplots(figsize=(10, 3.4))
ax.plot(ratios, marker="o", markersize=2, label="움직인 칸 비율")
ax.axhline(MIN_RATIO, linestyle="--", color="gray", label=f"기준 {MIN_RATIO}")
for k, (s, e) in enumerate(segments, start=1):
ax.axvspan(s, e, alpha=0.15, color="gray") # 찾은 구간을 옅게 칠한다
ax.text((s + e) / 2, max(ratios) * 1.02, f"자동차 {k}", ha="center")
ax.set_ylim(0, max(ratios) * 1.15)
ax.set_xlabel("프레임 번호")
ax.set_ylabel("움직인 칸 비율")
ax.set_title("주차장 입구의 움직임과 찾은 구간")
ax.legend(loc="center right")
plt.tight_layout()
plt.show()
기준을 0 으로 두면 구간이 4개로, 40~49번의 낙엽(비율 0.0009)까지 자동차로 셉니다. 기준을 0.005 로 올리자 낙엽이 빠지고 구간 3개, 곧 자동차 3대가 되어 정답과 맞았습니다(True). 기준을 0.01 로 더 올려도 3대였지만, 구간이 한두 장씩 짧아졌습니다. 자동차가 화면 끝에 조금만 걸친 장은 비율이 작기 때문입니다. 세 대는 9~33번(2.5초), 56~86번(3.1초), 106~126번(2.1초)에 지나갔고, 가장 빠른 노란 차의 구간이 가장 짧습니다. 대표 프레임은 구간 안에서 비율이 가장 큰 장이라 차가 화면에 온전히 들어온 26번, 63번, 120번이 골라졌고, 남색 차(63번)는 막 다 들어온 장이라 상자가 화면 왼쪽 끝 (0, 92, 81, 53) 에 붙어 있습니다. 동영상 150장이 사진 세 장의 요약(out_ch15_summary.png, 160×720)으로 줄었습니다. 다만 기준값 0.005 는 이 가상 동영상에 맞춘 값이므로, 실제 영상에서는 그림자와 조명에 맞춰 다시 정해야 합니다.