13장. Shape 분석과 ROI 처리
실습 4 — 사각형 ROI 잘라 내기
택배 사진(ch13_parcel.png)에서 송장을 네모로 잘라 냅니다. 잘라 낸 ROI 가 원본을 보는 창인지 복사본인지 직접 실험하고, 윤곽선의 boundingRect 로 송장 자리를 자동으로 찾아 여백을 주고 안전하게 자릅니다. 잘라 낸 송장은 out_ch13_label.png 로 저장합니다.
1단계. 좌표로 송장 잘라 내기
import cv2
import numpy as np
# 책상 위 택배 상자를 그린 가상의 그림. 송장의 이름·주소·번호는 지어낸 것이다
img = cv2.imread("ch13_parcel.png")
print("원본 크기:", img.shape)
# 송장 자리: 가로 60~204, 세로 40~152. 슬라이싱은 [세로 범위, 가로 범위], 끝 번호는 빠진다
label = img[40:153, 60:205]
print("ROI 크기:", label.shape)
print("칸 수:", img.shape[0] * img.shape[1], "→", label.shape[0] * label.shape[1])
# ROI 의 (0, 0) 은 원본의 (x=60, y=40) 과 같은 칸이다
print("ROI 왼쪽 위:", [int(v) for v in label[0, 0]], "/ 원본 [40, 60]:", [int(v) for v in img[40, 60]])원본 크기: (240, 320, 3)
ROI 크기: (113, 145, 3)
칸 수: 76800 → 16385
ROI 왼쪽 위: [245, 245, 245] / 원본 [40, 60]: [245, 245, 245]2단계. 창(보기)과 복사본 실험하기
view = img[40:153, 60:205] # 슬라이싱: 원본을 보는 창
copy = img[40:153, 60:205].copy() # copy(): 따로 숫자를 가진 복사본
print("창은 원본과 저장소를 함께 쓰나:", np.shares_memory(view, img))
print("복사본은 원본과 저장소를 함께 쓰나:", np.shares_memory(copy, img))
# 복사본의 왼쪽 위 10×10 을 빨강으로 칠해 본다
copy[:10, :10] = (0, 0, 255)
print("복사본을 칠한 뒤 원본 [40, 60]:", [int(v) for v in img[40, 60]])
# 창의 왼쪽 위 10×10 을 빨강으로 칠하면 원본도 칠해진다. 실험 뒤 되돌리려고 원래 값을 기억해 둔다
saved = view[:10, :10].copy()
view[:10, :10] = (0, 0, 255)
print("창을 칠한 뒤 원본 [40, 60]:", [int(v) for v in img[40, 60]])
view[:10, :10] = saved # 원본을 원래대로 되돌린다
print("되돌린 뒤 원본 [40, 60]:", [int(v) for v in img[40, 60]])창은 원본과 저장소를 함께 쓰나: True
복사본은 원본과 저장소를 함께 쓰나: False
복사본을 칠한 뒤 원본 [40, 60]: [245, 245, 245]
창을 칠한 뒤 원본 [40, 60]: [0, 0, 255]
되돌린 뒤 원본 [40, 60]: [245, 245, 245]3단계. boundingRect 로 송장 자리 찾기
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 송장은 아주 밝은 흰색(밝기 245)이다. 220 보다 밝은 곳을 흰색으로
_, white = cv2.threshold(gray, 220, 255, cv2.THRESH_BINARY)
found, _ = cv2.findContours(white, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
print("밝은 덩어리 수:", len(found))
# 가장 넓은 덩어리가 송장이다
paper = max(found, key=cv2.contourArea)
x, y, w, h = cv2.boundingRect(paper)
print("송장 상자 (x, y, 가로, 세로):", (x, y, w, h))
auto = img[y:y + h, x:x + w]
print("손으로 자른 ROI 와 같은가:", np.array_equal(auto, label))밝은 덩어리 수: 3
송장 상자 (x, y, 가로, 세로): (60, 40, 145, 113)
손으로 자른 ROI 와 같은가: True4단계. 여백을 주고 그림 밖으로 나가지 않게 자르기
import matplotlib.pyplot as plt
plt.rcParams["font.family"] = "Malgun Gothic"
plt.rcParams["axes.unicode_minus"] = False
def safe_crop(image, box, margin):
"""box=(x, y, 가로, 세로) 둘레에 margin 칸을 붙여 자르되, 그림 밖으로 나가지 않게 묶는다."""
H, W = image.shape[:2]
x, y, w, h = box
# np.clip(값, 아래, 위): 값을 0 ~ 그림 크기 사이로 밀어 넣는다
x0, x1 = int(np.clip(x - margin, 0, W)), int(np.clip(x + w + margin, 0, W))
y0, y1 = int(np.clip(y - margin, 0, H)), int(np.clip(y + h + margin, 0, H))
return image[y0:y1, x0:x1].copy(), (x0, y0, x1, y1)
box = (x, y, w, h)
# 여백을 그냥 빼면 시작 번호가 음수가 되어 빈 조각이 나온다
m = 70
print("그냥 자른 크기:", img[y - m:y + h + m, x - m:x + w + m].shape)
for margin in (0, 10, 70):
piece, corners = safe_crop(img, box, margin)
print(f"여백 {margin:2}: 자른 범위 {corners}, 크기 {piece.shape}")
# 여백 10 으로 자른 송장을 저장한다
label10, _ = safe_crop(img, box, 10)
print("저장:", cv2.imwrite("out_ch13_label.png", label10))
fig, axes = plt.subplots(1, 3, figsize=(11, 3.2))
for ax, margin in zip(axes, (0, 10, 70)):
piece, _ = safe_crop(img, box, margin)
ax.imshow(cv2.cvtColor(piece, cv2.COLOR_BGR2RGB))
ax.set_title(f"여백 {margin} {piece.shape[1]}×{piece.shape[0]}")
ax.axis("off")
plt.tight_layout()
plt.show()그냥 자른 크기: (13, 0, 3)
여백 0: 자른 범위 (60, 40, 205, 153), 크기 (113, 145, 3)
여백 10: 자른 범위 (50, 30, 215, 163), 크기 (133, 165, 3)
여백 70: 자른 범위 (0, 0, 275, 223), 크기 (223, 275, 3)
저장: True
코드 설명
img[40:153, 60:205]는 [세로 범위, 가로 범위] 로 자릅니다. 끝 번호는 빠지므로 세로 113줄, 가로 145칸입니다. ROI 안의 (0, 0) 은 원본의 (x=60, y=40) 칸입니다.- 창(
view)은np.shares_memory가 True 라 칠하면 원본 [40, 60] 칸도 [0, 0, 255] 가 되고, 복사본은 False 라 칠해도 원본이 [245, 245, 245] 그대로입니다. 창을 칠한 실험 뒤에는 기억해 둔 값으로 원본을 되돌렸습니다. - 밝기 220 으로 이진화하면 송장 말고도 스티커의 흰 글자가 잡혀 밝은 덩어리가 3개였습니다. 가장 넓은 덩어리를 고르면 송장이고, 그
boundingRect(60, 40, 145, 113) 로 자른 ROI 는 1단계에서 손으로 잰 좌표의 ROI 와 똑같았습니다. safe_crop()은np.clip으로 시작과 끝을 0 ~ 그림 크기 사이에 묶습니다. 그냥 빼면 음수 번호가 "끝에서부터"로 읽혀 빈 조각이 됩니다.