"""포스터에서 QR 흰 카드를 찾는다. "우상단에서 가장 큰 흰 블롭"을 QR로 삼던 검출기는 하늘 그러데이션·흰 글자띠·색 체크무늬를 줄줄이 걸었다. 배치 27장으로 캘리브레이션해 파인더 패턴(1:1:3:1:1)까지 확인한다. """ import numpy as np from PIL import Image from scipy import ndimage # QR은 우상단에만 있지 않다 — 우하단에 단 포스터를 통째로 놓친 적이 있다. 네 귀퉁이를 다 본다. CORNER_WINDOWS = ((0.70, 0.00, 1.00, 0.18), (0.70, 0.82, 1.00, 1.00), (0.00, 0.00, 0.30, 0.18), (0.00, 0.82, 0.30, 1.00)) FINDER_HIT_THRESHOLD = 30 def otsu_threshold(gray: np.ndarray) -> int: histogram, _ = np.histogram(gray, bins=256, range=(0, 256)) dark_counts = np.cumsum(histogram) light_counts = gray.size - dark_counts cumulative = np.cumsum(histogram * np.arange(256)) dark_mean = np.divide(cumulative, dark_counts, out=np.zeros(256), where=dark_counts > 0) light_mean = np.divide(cumulative[-1] - cumulative, light_counts, out=np.zeros(256), where=light_counts > 0) return int(np.argmax(dark_counts * light_counts * (dark_mean - light_mean) ** 2)) def finder_pattern_hits(binary: np.ndarray) -> int: """QR 파인더 패턴(모서리 겹눈)의 1:1:3:1:1 런 비율을 만족하는 스캔라인 수.""" hits = 0 for line in binary: change_points = np.flatnonzero(np.diff(line)) + 1 if len(change_points) < 4: continue bounds = np.concatenate(([0], change_points, [len(line)])) runs, values = np.diff(bounds), line[bounds[:-1]] for start in range(len(runs) - 4): if values[start] != 1: # 어두운 런에서 시작해야 한다 continue run = runs[start:start + 5] unit = run.sum() / 7.0 if unit >= 1 and abs(run[0] - unit) <= .6 * unit and abs(run[1] - unit) <= .6 * unit \ and abs(run[2] - 3 * unit) <= 1.2 * unit \ and abs(run[3] - unit) <= .6 * unit and abs(run[4] - unit) <= .6 * unit: hits += 1 break return hits def is_qr(poster: Image.Image, box: tuple[int, int, int, int]) -> bool: """밝은 블롭이 정말 QR인가. 정답과 오검출이 aspect·채도·잉크·파인더 어느 축으로도 겹치지 않는다.""" left, top, right, bottom = box width, height = right - left, bottom - top if not (0.75 <= width / max(height, 1) <= 1.35): return False if not (0.015 <= width / poster.width <= 0.15): return False card = poster.crop(box).convert("RGB") if max(width, height) > 400: # 파인더 스캔은 400px면 충분하다 scale = 400 / max(width, height) card = card.resize((max(1, round(width * scale)), max(1, round(height * scale))), Image.LANCZOS) pixels = np.asarray(card).astype(np.float32) if float(np.mean(pixels.max(2) - pixels.min(2))) > 22: # QR은 무채색이다 return False gray = pixels.mean(2) binary = (gray < otsu_threshold(gray)).astype(np.uint8) if not (0.15 <= float(binary.mean()) <= 0.60): # 글자는 더 성기고 반전 블록은 더 빽빽하다 return False return (finder_pattern_hits(binary) >= FINDER_HIT_THRESHOLD and finder_pattern_hits(binary.T) >= FINDER_HIT_THRESHOLD) def find_white_blobs(poster: Image.Image, window: tuple[float, float, float, float], top_n: int = 6) -> list[tuple[int, int, int, int]]: """정규 좌표 창 안의 밝은 블롭 bbox들. 큰 것부터.""" width, height = poster.size offset_x, offset_y = int(width * window[0]), int(height * window[1]) region = np.asarray(poster.convert("RGB")).astype(np.float32)[ offset_y:int(height * window[3]), offset_x:int(width * window[2])] if region.size == 0: return [] white = ndimage.binary_closing(region.min(2) > 205, np.ones((9, 9), bool)) labels, count = ndimage.label(white) if count == 0: return [] sizes = ndimage.sum(white, labels, range(1, count + 1)) boxes = ndimage.find_objects(labels, max_label=count) found = [] for index in np.argsort(sizes)[::-1][:top_n]: # 가장 큰 게 QR이 아닐 수도 있다 rows, cols = boxes[int(index)] found.append((offset_x + cols.start, offset_y + rows.start, offset_x + cols.stop, offset_y + rows.stop)) return found def qr_box(poster: Image.Image, strict: bool = True, window: tuple[float, float, float, float] | None = None ) -> tuple[int, int, int, int] | None: """QR 흰 카드의 bbox. 없으면 None. strict=True(원본 포스터용): is_qr을 통과한 블롭만. QR의 유무는 원본이 정한다. strict=False(모델 출력 검증용): 창 안의 가장 큰 흰 블롭. 모델이 뭉갠 QR은 파인더가 살아남지 못하므로, 묻는 것은 "QR인가"가 아니라 "예상한 자리에 흰 카드가 있는가"다. """ for corner in ((window,) if window else CORNER_WINDOWS): for box in find_white_blobs(poster, corner): if not strict or is_qr(poster, box): return box return None