8.3 화면 속 글자와 장면 인식 - 프레임 샘플링, 중복 제거, 비전 모델 실험
프레임을 줄이는 지각 해시 중복 제거, 로컬 비전 모델에 합성 프레임 4장을 읽혀 얻은 글자 인식 오차율과 장면 설명 오류, 프레임별 결과를 시간 구간으로 합치는 방법을 설명합니다.
#화면에서 얻을 수 있는 것
방송 화면에는 음성에 없는 정보가 많습니다. 뉴스의 자막띠(인명, 장소, 속보 문구), 스포츠의 점수판, 길거리의 간판, 화면에 박힌 날짜·시간이 대표적입니다. 이 글자를 읽어서 시간과 함께 저장하면 음성 인식으로는 찾을 수 없던 검색이 가능해집니다.
다만 모든 프레임을 분석할 수는 없습니다. 1시간 영상은 약 108,000프레임이고, 비전 모델이 한 장을 보는 데 몇 초씩 걸립니다(9장 2편에서 측정). 그래서 어떤 프레임을 볼 것인가가 비용과 품질을 함께 결정합니다.
#프레임을 고르는 세 가지 방법
| 방법 | 방식 | 장점 | 단점 |
|---|---|---|---|
| 고정 간격 | N초마다 1장 | 단순, 비용 예측 쉬움 | 짧게 나오는 자막을 놓침, 같은 화면을 반복 분석 |
| 장면 전환 기반 | 컷이 바뀔 때 1장 | 장면마다 대표 1장 | 한 장면 안에서 자막이 바뀌면 놓침 |
| 고정 간격 + 중복 제거 | 촘촘히 뽑고 비슷한 프레임은 버림 | 놓침이 적고 비용도 줄임 | 중복 판정 기준을 정해야 함 |
세 번째 방식이 실무에서 가장 균형이 좋습니다. 핵심은 "비슷한 프레임"을 값싸게 판정하는 방법입니다.
#지각 해시로 중복 프레임 걸러내기
이미지를 9×8 흑백으로 줄인 뒤, 각 가로 인접 화소의 밝기 증감을 비트로 만드는 dHash는 구현이 짧고 빠릅니다. 두 프레임의 해시가 몇 비트 다른지(해밍 거리) 로 유사도를 봅니다.
from PIL import Image
import numpy as np
def dhash(path, size=8):
im = Image.open(path).convert("L").resize((size + 1, size), Image.LANCZOS)
a = np.asarray(im, dtype=np.int16)
return (a[:, 1:] > a[:, :-1]).flatten()
def hamming(a, b):
return int((a != b).sum())
def dedupe(paths, thr=5):
"""앞에서부터 보면서 직전 '대표 프레임'과 해시 거리가 thr 이하이면 같은 장면으로 본다."""
keep, last = [], None
for p in paths:
h = dhash(p)
if last is None or hamming(h, last) > thr:
keep.append(p)
last = h
return keep
직접 만든 뉴스 자막 화면(f1)을 기준으로, 같은 화면에 변형을 주거나 다른 화면과 비교한 해밍 거리(64비트 중)입니다.
| 비교 대상 | 해밍 거리 |
|---|---|
| 밝기를 8% 올린 같은 화면 | 4 |
| 잡음(표준편차 6)을 얹은 같은 화면 | 2 |
| 가장자리를 약 8% 잘라 확대한 같은 화면 | 12 |
| 스코어보드 화면 | 31 |
| 간판 화면 | 25 |
| 글자 없는 풍경 화면 | 26 |
임계값 5에서 밝기·잡음 변형은 같은 화면으로, 다른 화면은 모두 별개로 판정되었습니다. 7장짜리 시퀀스(f1, 밝기 변형, 잡음 변형, f2, f2, f3, f4)에서 대표 프레임 4장(f1, f2, f3, f4)만 남았습니다.
#비전 모델에 직접 읽혀 보기
로컬에서 실행하는 비전 지원 모델(qwen3.8:27b)에 합성 프레임 4장을 주고 화면의 글자를 그대로 옮기고 장면을 한 문장으로 설명하게 했습니다. 정답이 확실하도록 글자는 직접 그려 넣었습니다.
f1: 뉴스 자막띠 —속보/광화문광장 가을 축제 개막/시민 3만여 명 방문... 오는 일요일까지f2: 스코어보드 —한빛 3 : 2 새롬/후반 38:12f3: 표지판과 날짜 —서울역 방면/Seoul Station 2.4km/2026.10.28 17:42f4: 글자가 전혀 없는 풍경
import json
import urllib.request
schema = {"type": "object",
"properties": {"texts": {"type": "array", "items": {"type": "string"}}, "scene": {"type": "string"}},
"required": ["texts", "scene"]}
prompt = ("이 이미지는 방송 영상의 한 프레임입니다. 화면에 보이는 글자를 줄 단위로 그대로 옮기고(없으면 빈 배열), "
"장면을 한 문장으로 설명하세요. 보이지 않는 글자를 추측해서 만들지 마세요. JSON으로만 답하세요.")
body = {"model": "qwen3.8:27b", "stream": False, "think": False, "format": schema,
"options": {"temperature": 0, "seed": 1},
"messages": [{"role": "user", "content": prompt, "images": [image_base64]}]}
req = urllib.request.Request("http://localhost:11434/api/chat", json.dumps(body).encode(), {"Content-Type": "application/json"})
result = json.loads(json.load(urllib.request.urlopen(req))["message"]["content"])
format에 JSON 스키마를 주면 모델이 스키마에 맞는 JSON만 출력하도록 제한됩니다. 결과(공백을 뺀 글자 오차율 CER 기준)입니다.
| 프레임 | 소요 시간 | 글자 인식 | CER |
|---|---|---|---|
f1 자막띠 |
13.5초 | 3줄 모두 정확 | 0.0% |
f2 스코어보드 |
8.0초 | 2줄 모두 정확 | 0.0% |
f3 표지판·날짜 |
10.3초 | 2026.10.28을 2026 10.28로 읽음(점 하나 누락) |
2.7% |
f4 글자 없음 |
7.7초 | 빈 배열(없는 글자를 만들지 않음) | - |
글자 인식은 이 합성 프레임에서는 매우 정확했습니다. 글자가 없는 프레임에서 글자를 지어내지 않은 점도 중요합니다. 다만 한 가지 오류(2026.10.28의 점)는 날짜처럼 정밀해야 하는 값이 조용히 틀릴 수 있음을 보여 줍니다.
#장면 설명은 훨씬 불안했습니다
같은 호출의 장면 설명입니다.
| 프레임 | 실제 | 모델의 설명 |
|---|---|---|
f1 |
파란 배경, 살색 원(얼굴), 검은 사각형(몸), 빨간·파란 자막 바 | "파란 하늘 아래 검은색 사각형 위에 노란색 원이 있는 단순한 그래픽" |
f2 |
초록 배경의 스코어보드와 흰 원 다섯 개 | "녹색 배경의 탁구대 위에 흰색 공이 다섯 개 놓여 있는 모습" |
f3 |
갈색 배경의 파란 표지판 | 대체로 정확 |
f4 |
보라·주황 배경에 검은 삼각형과 노란 원 | 대체로 정확 |
f1의 원은 노란색이 아니라 살색이고, f2에는 탁구대가 없습니다. 글자는 정확히 읽으면서 그림의 의미는 자신 있게 틀렸습니다. 이 두 장면 설명을 그대로 검색 색인에 넣으면 "탁구" 검색에 스코어보드 화면이 나옵니다. 그래서 다음처럼 역할을 나눕니다.
- 화면 속 글자(OCR 성격의 출력): 비교적 믿고 검색 색인에 쓰되, 날짜·숫자·고유명사는 신뢰도 표시와 검수 대상으로 둡니다.
- 장면 설명(캡션): 사람이 읽는 참고 문구로만 쓰고, 검색용 확정 태그로 자동 등록하지 않습니다. 쓰려면 사람이 검수하거나 신뢰도 기준을 둡니다.
#프레임별 결과를 시간 구간으로 합치기
10초마다 분석하면 같은 자막이 여러 샘플에 연달아 나옵니다. 이를 그대로 저장하면 같은 문구가 수십 개 행이 됩니다. 연속한 샘플에 계속 나타난 글자를 하나의 구간으로 합칩니다. 한 프레임에는 글자가 여러 개 있을 수 있으므로 샘플마다 텍스트 목록을 받습니다.
def merge_ocr(samples, interval):
"""samples: [(초, [텍스트, ...])] 시간순, 샘플 간격 interval(초).
연속한 샘플에 계속 나타난 텍스트를 하나의 구간으로 합친다. 구간은 [첫 관측, 마지막 관측 + interval)로 추정한다."""
spans, done = {}, [] # spans: 지금 이어지고 있는 텍스트 -> [첫 관측, 마지막 관측]
for t, texts in samples:
for tx in [k for k in spans if k not in texts]: # 이번 샘플에 없으면 구간을 닫는다
a, b = spans.pop(tx)
done.append((a, b + interval, tx))
for tx in texts:
if tx in spans:
spans[tx][1] = t
else:
spans[tx] = [t, t]
for tx, (a, b) in spans.items():
done.append((a, b + interval, tx))
return sorted(done)
입력 (간격 10초) 0~20초: 속보 / 30~40초: 한빛 3 : 2 새롬 / 50~60초: 속보 / 70~90초: 글자 없음 / 100초: 속보
출력 [(0, 30, '속보'), (30, 50, '한빛 3 : 2 새롬'), (50, 70, '속보'), (100, 110, '속보')]
[(0,'A'), (5,'B'), (10,'A')] (간격 5초) → [(0, 5, 'A'), (5, 10, 'B'), (10, 15, 'A')] ← A는 B가 끼어 있는 동안 이어지지 않음
같은 프레임에 두 글자 → 각각 따로 구간이 만들어짐
- 같은
속보라도 중간에 다른 화면이 끼거나 글자가 없는 샘플이 있으면 별개의 구간으로 나뉩니다(0~30초와 50~70초). 처음 만든 코드는 글자를 키로 마지막 시각만 갱신해서 중간에 다른 글자가 있어도 하나로 합쳐 버렸고, 단독 샘플은 시작과 끝이 같은(길이 0) 구간이 되어 8장 1편의CHECK (end_ms > start_ms)에 걸렸습니다. 지금 코드는 끝을마지막 관측 + 간격으로 추정해서 항상 길이가 있습니다. - 이 구간은 추정값입니다. 글자는 첫 관측보다 최대 한 간격 먼저 나타났을 수 있고, 마지막 관측 뒤로 최대 한 간격 더 남아 있었을 수 있습니다. 정확한 시작 시점이 중요하면 장면 전환이나 해시 변화가 감지된 지점 앞뒤를 더 촘촘히 다시 샘플링합니다.
- 중복 제거(앞의
dedupe)로 건너뛴 프레임은 직전 대표 프레임의 결과를 이어받은 것으로 샘플 목록에 넣어야 합니다. 건너뛴 샘플을 "글자 없음"으로 두면 구간이 끊깁니다. - 완전히 같은 문자열일 때만 합칩니다. OCR이 한 글자를 다르게 읽으면 구간이 쪼개지므로, 실제로는 편집 거리가 작으면 같은 텍스트로 간주하는 보정이 필요합니다.
#정리
- 프레임은 촘촘히 뽑고 지각 해시로 중복을 제거하는 방식이 균형이 좋습니다. 임계값은 자기 소재로 정합니다.
- 비전 모델은 이 합성 프레임에서 글자는 거의 정확했지만 그림의 의미 설명은 틀렸습니다. 글자와 캡션을 다르게 취급하세요.
- 프레임별 결과는 같은 글자가 이어지는 동안을 시간 구간으로 합쳐 저장합니다.
#참고 자료
링크는 2026-10-28에 열람이 되는 것을 확인했습니다. 규격과 도구 문서는 개정될 수 있으므로 적용하기 전에 최신 판을 확인하세요.