playreel/backend/utils/qr.py

130 lines
6.0 KiB
Python

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