127 lines
5.9 KiB
Python
127 lines
5.9 KiB
Python
"""⑥ 제목 훼손 게이트 — 픽셀 하드컷 + VLM 가독성 2단.
|
|
|
|
"파일이 존재하는가"만 보던 검증 탓에 구름이 제목을 덮은 클립이 검수대까지 간 적이 있다.
|
|
픽셀 지표 하나로는 갈리지 않아서 — 영역 평균차는 제목 뒤 불꽃과 제목 위 구름을 구분 못 하고
|
|
글리프 휘도차는 흰 구름이 흰 글자를 덮으면 0에 수렴한다 — 2단을 VLM 가독성 판정으로 둔다.
|
|
"""
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
from answers.title_gate_answer import TitleGateAnswer
|
|
from models.detect import Regions
|
|
from models.i2v import FrameVerdict, TitleGateVerdict
|
|
from settings import settings
|
|
from utils.common_llm import StructuredLLM
|
|
from utils.image import content_box, to_data_uri
|
|
from utils.prompt import load_prompt
|
|
from utils.qr import otsu_threshold
|
|
from utils.video import duration, frames_at
|
|
|
|
PIXEL_HARD_FAIL = 40.0 # 정상 최대 17.02, 재작화 81.28 (실측)
|
|
SAMPLE_FRACTIONS = (0.0, 0.25, 0.45, 0.70, 0.93)
|
|
MIN_TITLE_BOX_PX = 24
|
|
MIN_GLYPH_PIXELS = 300
|
|
ALIGN_RADIUS_PX = 14
|
|
CROP_MAX_SIZE = (640, 640)
|
|
|
|
TITLE_GATE_PROMPT = load_prompt("title_gate")
|
|
|
|
title_gate_llm = StructuredLLM("gpt-4o", settings.chatgpt_api_key)
|
|
|
|
|
|
def title_box_in_plate(frame: Image.Image, regions: Regions) -> tuple[int, int, int, int] | None:
|
|
"""원본 정규좌표 → 클립 프레임 안의 제목 상자.
|
|
|
|
content_box가 완전히 희거나 검은 행만 걷어내는 탓에 plate 높이가 원본 비율과 어긋나므로,
|
|
믿을 수 있는 가로를 기준으로 잡고 세로 배율은 원본 종횡비에서 유도한다.
|
|
"""
|
|
box = regions.regions.get("title")
|
|
if box is None:
|
|
return None
|
|
left, top, right, bottom = content_box(frame)
|
|
plate_width = right - left
|
|
source_width, source_height = regions.poster_size
|
|
full_height = plate_width * source_height / source_width
|
|
title = (left + round(box.x0 * plate_width),
|
|
top + round(box.y0 * full_height),
|
|
left + round(box.x1 * plate_width),
|
|
min(bottom, top + round(box.y1 * full_height)))
|
|
if title[2] - title[0] < MIN_TITLE_BOX_PX or title[3] - title[1] < MIN_TITLE_BOX_PX:
|
|
return None
|
|
return title
|
|
|
|
|
|
def align(gray: np.ndarray, reference: np.ndarray, mask: np.ndarray) -> np.ndarray:
|
|
"""생성 클립은 프레임 전체가 2~3% 드리프트해서 정렬 없이 재면 글자가 멀쩡해도
|
|
컷오프를 넘으므로(실측 41.9 → 정렬 후 24.9), ±14px 평행이동을 그리드로 찾아 되돌린다."""
|
|
best_score, best_dx, best_dy = None, 0, 0
|
|
for dy in range(-ALIGN_RADIUS_PX, ALIGN_RADIUS_PX + 1, 2):
|
|
for dx in range(-ALIGN_RADIUS_PX, ALIGN_RADIUS_PX + 1, 2):
|
|
shifted = np.roll(np.roll(gray, dy, axis=0), dx, axis=1)
|
|
score = float(np.abs(shifted[mask] - reference[mask]).mean())
|
|
if best_score is None or score < best_score:
|
|
best_score, best_dx, best_dy = score, dx, dy
|
|
return np.roll(np.roll(gray, best_dy, axis=0), best_dx, axis=1)
|
|
|
|
|
|
def pixel_score(crops: list[Image.Image]) -> float | None:
|
|
"""글리프 휘도차 A. 같은 색 가림은 못 잡아서 재작화·소거 하드컷에만 쓴다."""
|
|
reference = np.asarray(crops[0].convert("L"), dtype=np.float32)
|
|
dark = reference < otsu_threshold(reference)
|
|
mask = dark if dark.mean() <= 0.5 else ~dark
|
|
if mask.sum() < MIN_GLYPH_PIXELS:
|
|
return None
|
|
return max(float(np.abs(align(np.asarray(crop.convert("L"), dtype=np.float32),
|
|
reference, mask)[mask] - reference[mask]).mean())
|
|
for crop in crops[1:])
|
|
|
|
|
|
async def ask_legibility(crops: list[Image.Image], times: list[float],
|
|
title: str) -> TitleGateAnswer:
|
|
images = [(f"[{index}] t={at}s:", to_data_uri(crop, CROP_MAX_SIZE))
|
|
for index, (crop, at) in enumerate(zip(crops, times, strict=True))]
|
|
return await title_gate_llm.ask_with_images(
|
|
TitleGateAnswer,
|
|
f"기대 제목: {title!r}\n시점 {len(crops)}장. 각 이미지를 순서대로 판정하라.",
|
|
images, system=TITLE_GATE_PROMPT)
|
|
|
|
|
|
async def check_title(clip: bytes, regions: Regions) -> TitleGateVerdict:
|
|
"""판단 근거가 없으면(제목 상자·텍스트 없음) 통과시키고 사유를 남긴다."""
|
|
clip_seconds = duration(clip)
|
|
times = [round(max(0.0, min(clip_seconds - 0.05, fraction * clip_seconds)), 2)
|
|
for fraction in SAMPLE_FRACTIONS]
|
|
frames = frames_at(clip, times)
|
|
|
|
box = title_box_in_plate(frames[0], regions)
|
|
if box is None:
|
|
return TitleGateVerdict(passed=True, skip="title 상자가 없거나 너무 작음")
|
|
crops = [frame.crop(box) for frame in frames]
|
|
|
|
score = pixel_score(crops)
|
|
title = (regions.regions["title"].text or "").strip()
|
|
verdict = TitleGateVerdict(passed=True, samples=times,
|
|
pixel_score=round(score, 2) if score is not None else None)
|
|
|
|
if score is not None and score > PIXEL_HARD_FAIL:
|
|
# 정렬 후에도 넘는 값은 대개 배경 통과라 즉시 실패시키지 않고 VLM으로 넘긴다
|
|
verdict.pixel_hard = True
|
|
if not title:
|
|
verdict.passed = False
|
|
verdict.reason = f"획변화 {score:.1f} > {PIXEL_HARD_FAIL}, title.text 없어 VLM 판정 불가"
|
|
return verdict
|
|
if not title:
|
|
verdict.skip = "title.text 없음 — VLM 가독성 판정 불가"
|
|
return verdict
|
|
|
|
answer = await ask_legibility(crops, times, title)
|
|
verdict.frames = [FrameVerdict(index=frame.index, time=times[frame.index],
|
|
legible=frame.legible, broken=frame.broken)
|
|
for frame in answer.frames if frame.index < len(times)]
|
|
broken = [frame for frame in verdict.frames if not frame.legible]
|
|
if broken:
|
|
verdict.passed = False
|
|
verdict.reason = "제목 가독 훼손: " + "; ".join(
|
|
f"t={frame.time}s {frame.broken}" for frame in broken)
|
|
return verdict
|