playreel/backend/utils/qr.py
2026-09-02 09:37:58 +09:00

110 lines
5.2 KiB
Python

"""포스터에서 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