130 lines
6.0 KiB
Python
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
|