19장. Vision 데이터 파이프라인
실습 1 — 단계 함수 만들기: 수집과 거르기
입고 폴더 ch19_incoming/ 에서 그림을 모으는 수집 함수와, 네 관문으로 거르는 거르기 함수를 만듭니다. 그림마다 통과·탈락과 이유를 표로 만들고, 섞어 둔 문제의 정답(issues.csv)과 견줍니다. 통과한 그림 목록은 실습 2가 쓰도록 out_ch19_passed.csv 로 저장합니다.
1단계. 설정과 수집 함수
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 02단계. 앞 세 관문을 한 함수로
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단계. 중복 관문을 붙인 거르기 단계
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단계. 정답과 견주고 저장하기
# 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 를 그대로 썼다면 이 둘을 중복으로 잘못 뺐을 것입니다.