OpenCV 비전 실무19장 · Vision 데이터 파이프라인14 / 21← 이전목차다음 →MetaContents

19장. Vision 데이터 파이프라인

실습 1 — 단계 함수 만들기: 수집과 거르기

입고 폴더 ch19_incoming/ 에서 그림을 모으는 수집 함수와, 네 관문으로 거르는 거르기 함수를 만듭니다. 그림마다 통과·탈락과 이유를 표로 만들고, 섞어 둔 문제의 정답(issues.csv)과 견줍니다. 통과한 그림 목록은 실습 2가 쓰도록 out_ch19_passed.csv 로 저장합니다.

1단계. 설정과 수집 함수

Python
from pathlib import Path
import pandas as pd

# 이 실습이 쓰는 기준값을 한곳에 모은다(본문 "설정을 한곳에")
CONFIG = {
    "exts": [".png", ".jpg", ".jpeg"],   # 그림으로 볼 확장자
    "min_side": 64,                     # 짧은 변이 이보다 작으면 탈락
    "dark_drop": 30,                    # 평균 밝기가 이보다 작으면 탈락
    "blur_min": 30,                     # 흐림 점수가 이보다 작으면 탈락
    "hash_max": 2,                      # 평균 해시 거리가 이 이하면 중복
}

def collect(folder, config):
    """수집 단계: 폴더에서 그림 파일만 이름순으로 골라 항목 사전의 목록으로 돌려준다."""
    items = []
    for p in sorted(Path(folder).iterdir()):
        # suffix.lower(): 확장자를 소문자로 바꿔 .PNG 도 잡는다(17차시)
        if p.is_file() and p.suffix.lower() in config["exts"]:
            items.append({"file": p.name, "source": p.as_posix(), "bytes": p.stat().st_size})
    return items

items = collect("ch19_incoming", CONFIG)
# 그림이 아니라서 수집에서 빠진 파일도 확인한다
skipped = [p.name for p in sorted(Path("ch19_incoming").iterdir()) if p.suffix.lower() not in CONFIG["exts"]]
print("모은 그림:", len(items), "장 · 수집에서 뺀 파일:", skipped)
print(pd.DataFrame(items).tail(4).to_string(index=False))
출력
모은 그림: 24 장 · 수집에서 뺀 파일: ['answer.csv', 'issues.csv']
        file                     source  bytes
belt_021.png ch19_incoming/belt_021.png   6210
belt_022.jpg ch19_incoming/belt_022.jpg   8775
belt_023.png ch19_incoming/belt_023.png    150
belt_024.png ch19_incoming/belt_024.png      0

2단계. 앞 세 관문을 한 함수로

Python
import cv2

def blur_score(img):
    """16차시의 흐림 점수: 흑백 Laplacian 의 분산."""
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    return float(cv2.Laplacian(gray, cv2.CV_64F).var())

def check_quality(item, config):
    """읽기·밝기·흐림 관문. (그림 또는 None, 결과, 이유, 잰 값 사전)을 돌려준다."""
    if item["bytes"] == 0:                              # 관문 1: 빈 파일
        return None, "탈락", "빈 파일", {}
    img = cv2.imread(item["source"])
    if img is None:                                     # 관문 1: 깨진 파일은 None
        return None, "탈락", "깨짐", {}
    h, w = img.shape[:2]
    if min(h, w) < config["min_side"]:                  # 관문 1: 너무 작은 그림
        return None, "탈락", f"너무 작음({w}x{h})", {}
    mean = round(float(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).mean()), 1)
    blur = round(blur_score(img), 1)
    scores = {"mean": mean, "blur": blur}
    if mean < config["dark_drop"]:                      # 관문 2: 아주 어두움
        return None, "탈락", f"아주 어두움({mean})", scores
    if blur < config["blur_min"]:                       # 관문 3: 흐림
        return None, "탈락", f"흐림({blur})", scores
    return img, "통과", "", scores

# 정상, 아주 어두움, 깨짐 그림에 먼저 시험한다
for it in items:
    if it["file"] in ("belt_001.png", "belt_011.png", "belt_023.png"):
        img, result, reason, scores = check_quality(it, CONFIG)
        print(it["file"], result, reason, scores)
출력
belt_001.png 통과  {'mean': 69.6, 'blur': 95.1}
belt_011.png 탈락 아주 어두움(21.7) {'mean': 21.7, 'blur': 17.3}
belt_023.png 탈락 깨짐 {}

3단계. 중복 관문을 붙인 거르기 단계

Python
import hashlib

def md5_of(path):
    """16차시: 파일의 바이트로 만든 지문(32글자)."""
    with open(path, "rb") as f:
        return hashlib.md5(f.read()).hexdigest()

def ahash(img):
    """16차시: 8x8 로 줄인 흑백에서 평균보다 밝은 칸을 1 로 적은 64칸 지문."""
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    small = cv2.resize(gray, (8, 8), interpolation=cv2.INTER_AREA)
    return (small > small.mean()).flatten()

def filter_stage(items, config):
    """거르기 단계: 네 관문을 차례로 지나게 하고 (통과한 항목, 기록) 을 돌려준다."""
    passed, log, seen = [], [], []                      # seen: 먼저 통과한 그림의 (이름, MD5, 평균 해시)
    for item in items:
        img, result, reason, scores = check_quality(item, config)
        if img is not None:                             # 관문 4: 앞에서 통과한 그림과 견준다
            digest, bits = md5_of(item["source"]), ahash(img)
            for other, other_digest, other_bits in seen:
                dist = int((bits != other_bits).sum())  # 해밍 거리
                if digest == other_digest or dist <= config["hash_max"]:
                    result, reason = "탈락", f"중복({other}, 거리 {dist})"
                    break
            if result == "통과":
                seen.append((item["file"], digest, bits))
                passed.append({**item, **scores, "img": img})   # ** 로 두 사전을 합친다
        log.append({"file": item["file"], "stage": "거르기", "result": result, "reason": reason, **scores})
    return passed, log

passed, log = filter_stage(items, CONFIG)
log_df = pd.DataFrame(log)
print("통과:", len(passed), "장 / 탈락:", int((log_df["result"] == "탈락").sum()), "장")
print(log_df[log_df["result"] == "탈락"][["file", "reason"]].to_string(index=False))
출력
통과: 18 장 / 탈락: 6 장
        file                 reason
belt_011.png           아주 어두움(21.7)
belt_013.png                흐림(0.9)
belt_021.png 중복(belt_003.png, 거리 0)
belt_022.jpg 중복(belt_010.png, 거리 0)
belt_023.png                     깨짐
belt_024.png                   빈 파일

4단계. 정답과 견주고 저장하기

Python
# issues.csv: 일부러 섞은 문제의 정답. 이 중 "버려야 할" 문제와 실제 탈락이 맞는지 본다
issues = pd.read_csv("ch19_incoming/issues.csv")
check = issues.merge(log_df[["file", "result", "reason"]], left_on="filename", right_on="file")
print(check[check["issue"] != "정상"][["filename", "issue", "result"]].to_string(index=False))

# 정상 그림 가운데 잘못 탈락한 것이 있는지
wrong = check[(check["issue"] == "정상") & (check["result"] == "탈락")]
print("정상인데 탈락:", wrong["filename"].tolist())

# 통과한 그림끼리 가장 가까운 해밍 거리: 기준 2 와의 틈을 본다(16차시 실습 3)
bits = {p["file"]: ahash(p["img"]) for p in passed}
names = list(bits)
closest = min((int((bits[a] != bits[b]).sum()), a, b) for i, a in enumerate(names) for b in names[i + 1:])
print("통과한 그림끼리 가장 가까운 거리:", closest)

# 실습 2가 쓰도록 통과 목록(그림 배열은 빼고)과 기록을 저장한다
pd.DataFrame([{k: v for k, v in p.items() if k != "img"} for p in passed]).to_csv(
    "out_ch19_passed.csv", index=False, encoding="utf-8-sig")
log_df.to_csv("out_ch19_filter_log.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch19_passed.csv, out_ch19_filter_log.csv")
출력
    filename  issue result
belt_004.png    어두움     통과
belt_008.jpg    노이즈     통과
belt_011.png 아주 어두움     탈락
belt_013.png     흐림     탈락
belt_016.png  크기 다름     통과
belt_021.png     중복     탈락
belt_022.jpg     중복     탈락
belt_023.png     깨짐     탈락
belt_024.png   빈 파일     탈락
정상인데 탈락: []
통과한 그림끼리 가장 가까운 거리: (4, 'belt_001.png', 'belt_004.png')
저장: out_ch19_passed.csv, out_ch19_filter_log.csv

코드 설명

  • collect 는 확장자로 그림만 고르고 파일 이름, 경로, 바이트 수를 담은 사전을 만듭니다. 그림을 읽지는 않습니다. 읽어 보는 일은 거르기 단계의 몫이라, 수집은 한 가지 일만 합니다.
  • check_quality 는 17장의 process 처럼 걸리면 곧바로 (None, "탈락", 이유, 잰 값) 을 돌려줍니다. 밝기와 흐림 점수는 통과한 그림에도 기록해 두어 다듬기 단계가 다시 재지 않게 합니다.
  • filter_stage 의 중복 관문은 이미 통과한 그림(seen)하고만 견줍니다. 이름순으로 돌기 때문에 belt_003.png 가 남고 복사본 belt_021.png 가 빠집니다. belt_022.jpg 는 다시 저장해 MD5 는 다르지만 평균 해시 거리가 0 이라 잡힙니다.
  • 탈락 6장이 issues.csv 에서 버려야 할 문제(깨짐, 빈 파일, 아주 어두움, 흐림, 중복 둘)와 꼭 맞고, 정상 그림은 하나도 탈락하지 않았습니다. 어두움, 노이즈, 크기 다름 3장은 통과해 실습 2의 다듬기로 넘어갑니다.
  • 통과한 그림끼리 가장 가까운 거리는 4(belt_001.png 와 어두운 belt_004.png)입니다. 기준 2 와 4 사이에 틈이 있어 이 묶음에서는 2 가 알맞고, 16장의 5 를 그대로 썼다면 이 둘을 중복으로 잘못 뺐을 것입니다.
← 이전 절19장 · 실습 지도다음 절 →19장 · 실습 2 — 다듬기 함수와 단계 잇기