OpenCV 비전 실무8장 · Threshold, Adaptive Threshold, Otsu18 / 21← 이전목차다음 →MetaContents

8장. Threshold, Adaptive Threshold, Otsu

실습 5 — Otsu 로 자동 임계값 정하기

부품 그림에서 Otsu 가 고른 임계값을 읽고, 같은 계산을 NumPy 로 직접 짜서 견줍니다. 노이즈를 더한 그림에서 블러를 먼저 할 때와 하지 않을 때도 견줍니다.

1단계. OpenCV 의 Otsu

Python
import cv2
import numpy as np

gray = cv2.imread("ch08_parts.png", cv2.IMREAD_GRAYSCALE)
# THRESH_OTSU: 임계값을 자동으로 고른다. thresh 자리의 0 은 무시된다
ret, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print("Otsu 가 고른 임계값:", int(ret))
print("검은 칸(부품) 비율:", round(float((otsu == 0).mean()), 3))
출력
Otsu 가 고른 임계값: 136
검은 칸(부품) 비율: 0.176

2단계. NumPy 로 Otsu 직접 계산하기

Python
def my_otsu(gray):
    """후보 t 마다 '왼쪽 비율 × 오른쪽 비율 × (두 평균의 차)²' 를 계산해 가장 큰 t 를 돌려준다."""
    # np.bincount: 0~255 의 값마다 칸이 몇 개인지 센다(히스토그램과 같다)
    hist = np.bincount(gray.ravel(), minlength=256)
    values = np.arange(256)
    total = hist.sum()
    best_t, best_score = 0, -1.0
    for t in range(256):
        left, right = hist[:t + 1], hist[t + 1:]          # t 이하, t 보다 큰 무리
        if left.sum() == 0 or right.sum() == 0:           # 한쪽이 비면 나눈 것이 아니다
            continue
        w0, w1 = left.sum() / total, right.sum() / total  # 두 무리의 비율
        m0 = (left * values[:t + 1]).sum() / left.sum()   # 왼쪽 무리의 평균 밝기
        m1 = (right * values[t + 1:]).sum() / right.sum() # 오른쪽 무리의 평균 밝기
        score = w0 * w1 * (m0 - m1) ** 2
        if score > best_score:                            # 동점이면 먼저 찾은(작은) t 를 남긴다
            best_t, best_score = t, score
    return best_t

print("직접 계산한 임계값:", my_otsu(gray))
print("OpenCV 와 같은가:", my_otsu(gray) == int(ret))

# 본문의 픽셀 8개로도 견준다
pixels = np.array([[30, 40, 50, 90, 120, 160, 170, 180]], dtype=np.uint8)
r8, _ = cv2.threshold(pixels, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print("픽셀 8개 - 직접:", my_otsu(pixels), "/ OpenCV:", int(r8))
출력
직접 계산한 임계값: 136
OpenCV 와 같은가: True
픽셀 8개 - 직접: 90 / OpenCV: 90

3단계. 히스토그램에 임계값 선 긋기

Python
import matplotlib.pyplot as plt

plt.rcParams["font.family"] = "Malgun Gothic"
plt.rcParams["axes.unicode_minus"] = False

hist = cv2.calcHist([gray], [0], None, [32], [0, 256]).ravel()
fig, axes = plt.subplots(1, 2, figsize=(11, 3.4))
axes[0].bar(np.arange(32) * 8, hist, width=8, align="edge")
axes[0].axvline(ret, color="red", linestyle="--", label=f"Otsu 임계값 {int(ret)}")
axes[0].set_xlim(0, 256)
axes[0].set_xlabel("밝기")
axes[0].set_ylabel("픽셀 수")
axes[0].legend(loc="upper center")
axes[1].imshow(otsu, cmap="gray", vmin=0, vmax=255)
axes[1].set_title("Otsu 결과")
axes[1].axis("off")
plt.tight_layout()
plt.show()
부품 그림의 히스토그램에 Otsu 임계값을 빨간 점선으로 그은 그래프와 Otsu 결과

4단계. 노이즈가 있을 때 블러 먼저 하기

Python
# 씨앗 8 로 고정한 노이즈(평균 0, 표준편차 35)를 칸마다 더하고 0~255 로 자른다
rng = np.random.default_rng(8)
noisy = np.clip(gray + rng.normal(0, 35, gray.shape), 0, 255).astype(np.uint8)
# 6차시에서 배운 가우시안 블러: 7×7 네모 안에서 가운데에 무게를 더 주어 섞는다
blur = cv2.GaussianBlur(noisy, (7, 7), 0)

r1, direct = cv2.threshold(noisy, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
r2, blurred = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# 노이즈 없는 원본의 Otsu 결과(otsu)와 같은 칸의 비율로 깨끗함을 잰다
for name, r, out in [("바로 Otsu", r1, direct), ("블러 뒤 Otsu", r2, blurred)]:
    print(f"{name}: 고른 값 {int(r)}, 원본 결과와 같은 칸 {round(float((out == otsu).mean()), 3)}")

fig, axes = plt.subplots(1, 3, figsize=(11, 3))
for ax, (title, im) in zip(axes, [("노이즈 낀 그림", noisy), (f"바로 Otsu ({int(r1)})", direct),
                                  (f"블러 뒤 Otsu ({int(r2)})", blurred)]):
    ax.imshow(im, cmap="gray", vmin=0, vmax=255)
    ax.set_title(title)
    ax.axis("off")
plt.tight_layout()
plt.show()
출력
바로 Otsu: 고른 값 144, 원본 결과와 같은 칸 0.926
블러 뒤 Otsu: 고른 값 138, 원본 결과와 같은 칸 0.997
노이즈 낀 부품 그림, 바로 Otsu 한 결과, 블러 뒤 Otsu 한 결과를 나란히 놓은 결과

코드 설명

  • cv2.THRESH_BINARY + cv2.THRESH_OTSU 를 주면 thresh 자리의 0 은 무시되고, Otsu 가 고른 136 이 첫 값으로 돌아옵니다. 검은 칸 비율 0.176 은 실습 1의 임계값 127(0.175)과 거의 같습니다.
  • my_otsu 는 후보 0~255 마다 "왼쪽 비율 × 오른쪽 비율 × (두 평균의 차)²" 를 계산해 가장 큰 후보를 고릅니다. 부품 그림에서 136, 본문의 픽셀 8개에서 90 으로 OpenCV 와 같습니다. 동점이면 먼저 찾은 작은 값을 남겨 OpenCV 처럼 90 이 나옵니다.
  • np.bincount 는 0~255 의 값마다 칸 수를 세는 함수로, cv2.calcHist 를 구간 256개로 쓴 것과 같은 값을 셉니다.
  • 노이즈를 더하면 Otsu 가 고른 값은 144 로 조금만 바뀌지만, 원본 결과와 같은 칸이 0.926 으로 떨어져 작업대에 검은 점이 가득합니다. 7×7 가우시안 블러 뒤에는 고른 값이 138 로 원본(136)에 가까워지고, 같은 칸도 0.997 로 거의 깨끗해집니다.
← 이전 절8장 · 실습 4 — blockSize 와 C 바꿔 보기다음 절 →8장 · 프로젝트 1 — 영수증 글자 살리기