"""포스터에서 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을 통과한 블롭만 돌려준다. strict=False(모델 출력 검증용)는 창 안의 가장 큰 흰 블롭을 돌려주는데, 모델이 뭉갠 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 def qr_card(poster: Image.Image, pad: int = 14) -> Image.Image | None: """QR 흰 카드를 오려낸다. 둥근 모서리라 사각 크롭엔 배경이 딸려와 채도로 걷어낸다. pad는 카드 둘레에 덧대는 흰 여백(quiet zone). 포스터 안쪽 제자리에 덮을 때는 카드 자체에 여백이 있으므로 0을 준다. """ box = qr_box(poster) if box is None: return None card = np.asarray(poster.crop(box).convert("RGB")).astype(np.float32) card[(card.max(2) - card.min(2)) > 35] = 255.0 # 유채색 프린지 → 흰색 cropped = Image.fromarray(card.astype(np.uint8)) if pad == 0: return cropped padded = Image.new("RGB", (cropped.width + pad * 2, cropped.height + pad * 2), (255, 255, 255)) padded.paste(cropped, (pad, pad)) return padded