[Langfuse 12] OCR과 Dataset으로 생성 이미지 품질 평가하기

dy·6일 전
post-thumbnail

Langfuse 12 — 생성 이미지의 품질 평가

이 글은 Langfuse 실습 시리즈의 12편입니다. 11편에서 기록한 이미지 산출물을 Dataset에 넣고, 같은 요구사항에 대해 여러 생성 설정의 결과를 비교합니다.

이 글에서 다룰 주제

  • 이미지 요구사항을 파일·문구·시각 구성으로 나누는 방법
  • 생성된 PNG를 Langfuse Dataset의 미디어로 저장하는 방법
  • 로컬 OCR과 명시적인 시각 검토 결과를 서로 다른 Score로 기록하는 방법
  • 저장된 이미지의 재평가와 실제 재생성 실험을 구분하는 이유

주요 단어 · Multi-modal Dataset · Experiment · OCR · Rubric · Media Reference · Reproducibility


“이미지가 생성됐다”는 사실과 “요청한 이미지를 얻었다”는 판단은 다릅니다. PNG 파일을 열 수 있어도 글자가 틀리거나, 요청한 제목이 빠지거나, 배경과 글자 색이 겹쳐 읽기 어려울 수 있습니다. 이미지 생성 모델을 운영하려면 성공 응답 외에 결과물 자체를 평가할 기준이 필요합니다.

이번 예제에는 두 가지 포스터 요청이 있습니다. poster_langfuse는 짙은 파란 배경과 LANGFUSE LAB, poster_local은 짙은 주황색(burnt orange) 배경과 LOCAL IMAGE를 요구합니다. 둘 다 제목 아래 작은 흰색 전구 윤곽, 충분한 여백, 추가 문구 없는 구성을 요청했습니다. 같은 사례와 seed에 대해 생성 설정을 바꾼 이미지들을 비교합니다. Langfuse가 이미지의 정답을 스스로 알아내는 것은 아닙니다. 평가 기준과 실행 코드를 준비하면 그 결과를 산출물·설정·실험에 연결해 저장하고 비교할 수 있습니다.

비교 대상은 로컬 Qwen/Qwen-Image-2.1로 생성한 원본 PNG 네 장입니다. 두 사례에 각각 20·40 steps를 적용했고 seed는 42, 크기는 1,024×1,024로 고정했습니다. 파일을 다시 생성하지 않고 같은 바이트를 읽어 평가했습니다. 파이프라인 동작 확인용 smoke 이미지와 별도로 생성한 다양성 예제는 이 비교의 분모에 포함하지 않습니다.

1. 평가 기준 나누기

Rubric은 평가자가 같은 대상을 일관되게 판단하도록 만드는 구체적인 기준입니다. 예를 들어 “예쁜가?”보다 “필수 제목이 정확하고 본문 크기에서 읽히는가?”가 결과를 설명하고 개선하기 쉽습니다.

이번에는 다음 네 점수를 따로 기록합니다.

점수값실제로 확인하는 것
image-dimensions-match0 또는 1이미지의 가로·세로 픽셀이 요청값과 같은지
image-required-text-coverage0~1OCR로 추출한 문자열에서 필수 문구를 몇 개 찾았는지
image-review-prompt-alignment0·1·2요청한 대상·배경·배치가 시각적으로 얼마나 맞는지
image-review-readability0·1·2필수 글자가 실제로 읽히는지

각 사례의 원본 prompt와 expected.required_text를 먼저 읽고 다음 기준을 적용합니다. 모든 사례에 파란 배경이나 같은 제목을 요구하지 않습니다.

값Prompt alignmentReadability
0핵심 대상·배경·구성이 요청과 다르거나 빠짐필수 문구 누락·오탈자·판독 불가
1대체로 맞지만 배경·전구·여백·추가 문구 등에 눈에 띄는 불일치필수 문구는 읽히지만 글자 형태·간격·대비에 눈에 띄는 문제
2해당 사례가 요청한 대상·배경·구성 조건을 충족해당 사례의 필수 문구가 정확하고 뚜렷하게 읽힘

예를 들어 poster_local의 주황색 배경은 요청에 맞는 결과입니다. poster_langfuse의 파란색 기준을 적용해 감점하지 않습니다. reason에는 “제목 아래 흰색 전구가 있고 추가 문구가 없다”처럼 실제로 관찰한 근거를 씁니다.

이번 학습 실험의 채택 기준은 해상도 일치, alignment=2, readability=2를 모두 만족하는 것입니다. 시각 검토가 없으면 채택 판단을 보류합니다. OCR coverage가 1 미만이면 실제 문구를 다시 확인하고 이유를 남깁니다. OCR이 실패했다는 이유만으로 시각적으로 올바른 이미지를 자동 탈락시키지는 않습니다. 이 기준은 이번 산출물의 검토 기준이며 배포를 자동 제어하는 gate는 구성하지 않았습니다.

이번 검토 점수와 채택 판단은 AI 보조 도구가 이미지를 보고 작성한 학습용 평가이며, 독립된 디자이너나 실제 사용자의 정답이 아닙니다. 평가자가 다른 경우 같은 rubric을 적용했는지와 의견이 갈린 이유를 함께 확인해야 합니다.

숫자를 나누는 이유는 실패 원인마다 개선 방법이 다르기 때문입니다. 해상도가 틀렸다면 호출 설정을 고쳐야 합니다. OCR이 문구를 찾지 못했다면 실제 이미지의 오탈자와 OCR 인식 실패를 구분해야 합니다. 글자는 맞지만 읽기 어렵다면 대비·크기·배치 요구사항을 더 분명하게 해야 합니다.

저장된 PNG를 파일 검사·로컬 OCR·명시적 AI 시각 검토로 나누어 평가하는 흐름

그림의 세 평가 경로는 서로 다른 질문에 답합니다. 형식 검사는 파일을 사용할 수 있는지, OCR은 필요한 문구를 인식했는지, 시각 검토는 요청에 맞고 읽기 쉬운지를 봅니다. 하나의 숫자로 합치기 전에 각 경로의 결과와 한계를 읽습니다.

2. 이미지 파일과 문구 확인

먼저 PNG의 서명, 필수 청크, 청크 CRC를 검사하고 가로·세로를 읽습니다. CRC는 파일의 손상 여부를 확인하는 값이며, 이미지의 의미나 디자인 품질을 보증하지 않습니다. macOS ImageIO로 실제 디코딩한 픽셀 크기도 헤더 값과 대조합니다.

문자 인식에는 macOS의 Apple Vision OCR을 사용합니다. OCR은 이미지의 글자 모양을 문자열로 바꾸는 기술입니다. VNRecognizeTextRequest의 정확도 우선 모드와 영어·한국어 중 지원되는 언어를 선택하고, 언어 교정은 끕니다. 이미지 안의 잘못된 문구를 사전 지식으로 고쳐 읽는 영향을 줄이기 위해서입니다. Apple의 이미지 문자 인식 문서

전체 실습 스크립트는 인식한 문구·confidence·경계 상자를 보관합니다. 7절의 최소 예제는 문구·크기·hash·OCR revision을 출력합니다. confidence는 OCR 엔진이 인식 결과에 부여한 값이며, 사람이 읽기 쉬운 정도나 디자인 품질 점수가 아닙니다.

다음은 OCR 결과를 점수로 바꾸는 핵심 계산입니다. ocr_result["lines"]는 7절의 Swift 코드가 출력하는 문자열 배열이고, required_text는 해당 사례의 필수 문구입니다. 전체 실습 스크립트에는 confidence·경계 상자도 포함되지만, 공개 예제의 계산은 아래 문자열 배열 형식으로 통일했습니다.

import unicodedata

def normalized_text(value):
    return "".join(
        unicodedata.normalize("NFKC", value).casefold().split()
    )

recognized = normalized_text("\n".join(ocr_result["lines"]))
matches = {
    text: normalized_text(text) in recognized
    for text in required_text
}
coverage = sum(matches.values()) / len(matches) if matches else None

이 계산은 유니코드 표기·대소문자·공백을 정규화한 뒤 문자열 포함 여부를 확인합니다. LANGFUSE LAB 하나만 요구했다면 coverage는 0 또는 1입니다. 필수 문구가 세 개라면 0, 1/3, 2/3, 1 중 하나가 됩니다. 요구 문구가 없을 때는 점수를 1로 만들지 않고 평가 대상 없음으로 처리합니다.

여기에는 한계가 있습니다. 정규화는 줄바꿈·띄어쓰기 차이를 무시하고, 포함 검사만으로는 추가 글자나 잘못된 배치를 잡아내지 못합니다. OCR이 작은 글자를 놓칠 수도 있습니다. 따라서 coverage=1은 지정한 문구를 인식했다는 뜻이며, 오탈자·레이아웃·시각 품질을 모두 통과했다는 뜻은 아닙니다.

기존 로컬 텍스트 모델 qwen3:8b에 PNG를 보내 이미지 Judge로 사용하지 않습니다. OCR의 텍스트만 모델에 전달하면 문구의 의미는 검토할 수 있어도 실제 색·구도·글자 모양을 평가할 수는 없습니다.

3. 요구사항과 이미지 묶기

Dataset은 실험에서 비교할 입력과 기대 결과를 고정하는 단위입니다. 이미지 생성 평가에서도 모든 요청에 하나의 정답 그림이 필요한 것은 아닙니다. 이번에는 expected_output에 정확한 픽셀 크기와 필수 문구를 넣습니다. 시각 구성은 별도의 rubric으로 읽습니다.

같은 case_id에는 동일한 요구사항을 둡니다. variant는 생성 단계 수처럼 바꾼 설정을 나타냅니다. 같은 case의 변형마다 기대 문구나 해상도가 달라지면 어떤 설정이 나았는지 공정하게 비교하기 어렵습니다.

SDK 4.16.0에서는 이미지 바이트를 LangfuseMedia(content_bytes=..., content_type="image/png")로 감싸 create_dataset_item의 입력에 넣습니다. SDK가 미디어를 별도로 업로드하고 참조로 연결합니다. get_dataset()으로 읽은 미디어에는 다운로드용 참조 객체가 들어옵니다. 전체 변수와 파일명을 정의한 복사 가능한 예제는 7절에 모았습니다. 멀티모달 첨부 문서

이번 비교의 기대 크기는 생성 전에 정한 1,024×1,024픽셀입니다. 결과 이미지의 크기를 본 뒤 기대값을 그 크기로 바꾸면 검사가 의미를 잃습니다. 파이프라인 동작 확인을 위한 512×512·4-step smoke 이미지는 입력 명세에 남겨도 smoke=true 조건으로 비교 대상에서 제외합니다.

또한 파일별 SHA-256을 기록합니다. SHA-256은 같은 바이트를 다시 평가하는지 확인하는 식별값입니다. 시각적으로 비슷한 그림을 같은 이미지로 판정하는 지표는 아닙니다. 이미지를 다른 형식으로 저장하거나 메타데이터만 바꿔도 값이 달라질 수 있습니다.

같은 Dataset item에 묶은 두 이미지와 기대 크기·문구

Dataset item의 artifacts를 펼친 실제 화면입니다. 왼쪽의 steps20·steps40은 저장된 PNG 참조이고, 오른쪽 기대값은 두 결과에 공통으로 적용합니다.

4. 저장된 결과로 실험 비교

이번 Experiment의 task는 새 이미지를 생성하지 않습니다. 이미 생성해 보관한 이미지와 로컬에서 측정한 검사 결과를 선택해 반환하고, evaluator가 각 점수를 붙입니다. 따라서 생성 설정을 비교하는 저장 산출물 평가 실험입니다. 여러 번의 새 생성을 실행해 모델의 분산까지 측정한 벤치마크와 구분해야 합니다.

dataset.run_experiment(task=..., evaluators=[...])에서 task가 선택한 이미지를 반환하고 evaluator가 검사 결과를 Evaluation 객체로 반환합니다. SDK는 Dataset item·실험·출력·Score의 연결을 관리합니다. 이 평가 함수는 외부 Python에서 실행되며 Langfuse worker의 native 평가 규칙이 실행한 것이 아닙니다. 7절의 최소 예제는 해상도와 OCR 점수를 기록하고, 본 실습에서는 여기에 위 rubric의 시각 검토 점수도 추가합니다. SDK Experiments 문서

Langfuse에서 20·40 steps 실험의 네 점수를 나란히 비교

Experiment 화면에서 Baseline에 image-artifact-steps20, Compare에 image-artifact-steps40을 선택했습니다. 위 요약은 현재 페이지의 두 item 평균이고, 아래 두 행은 각각 LANGFUSE LAB·LOCAL IMAGE 사례입니다. 해상도·문구 점수는 1, 시각 점수는 2가 기준상 만점입니다.

UI에서는 Dataset을 열고 같은 case가 들어 있는 실행들을 선택해 비교합니다. 평균만 보지 말고 개별 item의 이미지와 Score comment를 함께 읽습니다. 해상도는 같고 OCR만 다르다면 두 이미지에서 실제 문구를 먼저 찾아야 합니다. OCR과 시각 검토가 어긋난 사례는 실패 원인을 알려 주는 자료가 됩니다.

SDK 4.16.0은 Experiment 관측에 sdk-experiment 환경을 지정합니다. 생성 Trace의 image-lab 환경 필터만 유지하면 실험 항목을 놓칠 수 있으므로, 실험 화면 또는 해당 환경에서 조회합니다. 프로젝트 내 구분을 위한 environment: image-lab 메타데이터와 실제 관측의 environment 필드는 구분합니다.

실험 화면의 latency는 이 task의 수행 구간에 해당합니다. 원래 이미지 생성은 이전에 끝났으므로 이 latency를 이미지 생성 속도로 해석하면 안 됩니다. 생성 시간은 11편의 Generation metadata에 기록한 generation_latency_seconds에서 읽습니다. 이 값은 파이프라인 호출부터 MPS 동기화까지 측정하며 PNG 저장·미디어 업로드를 제외합니다. 반면 UI의 Generation latency에는 PNG 저장 등 관측을 닫기 전 작업도 포함되므로 같은 값으로 대체하지 않습니다. 비용·성능 비교는 13편에서 다룹니다.

5. 실제 결과를 해석하는 기준

원본 네 장을 macOS Vision OCR과 규칙으로 검사한 실제 결과입니다. OCR은 VNRecognizeTextRequest revision 3, 정확도 우선 모드, 언어 교정 끔으로 실행했습니다. 두 시각 점수는 위 기준을 적용한 AI 보조 검토 결과입니다.

사례Steps실제 크기OCR에서 읽은 제목CoverageAlignmentReadability학습용 채택
poster_langfuse201,024×1,024LANGFUSE LAB 한 줄1.022채택
poster_langfuse401,024×1,024LANGFUSE, LAB 두 줄1.022채택
poster_local201,024×1,024LOCAL IMAGE 한 줄1.022채택
poster_local401,024×1,024LOCAL, IMAGE 두 줄1.022채택

각 step 설정의 표본은 N=2입니다. 20 steps와 40 steps 모두 해상도 일치 2/2, 필수 문구 인식 2/2, alignment 평균 2.0, readability 평균 2.0이었습니다. 두 줄 제목도 공백·줄바꿈 정규화 뒤 필수 문구와 일치합니다. 이번 네 결과에서는 정해 둔 채택 기준을 모두 만족했으므로 학습용 채택은 4/4입니다.

Langfuse에는 같은 두 Dataset item으로 image-artifact-steps20과 image-artifact-steps40 실험을 만들고, 네 산출물에 네 종류씩 총 16개 Score를 기록했습니다. 이 평가에서는 이미지 생성 모델을 다시 호출하지 않았습니다.

기록 후 공개 GET API로 Dataset의 두 항목과 기대값, 두 실험의 입력 연결·출력·점수, 저장된 네 이미지 바이트의 SHA-256을 확인했습니다. 36개 저장 검사가 모두 통과했습니다. 이는 결과와 이미지가 의도한 대상으로 저장됐다는 확인이며, 시각 점수 자체를 독립 전문가가 검증했다는 뜻은 아닙니다.

5.1 같은 문구의 다른 배치

아래는 poster_langfuse의 두 원본입니다. 20 steps는 제목이 한 줄이고, 40 steps는 LANGFUSE와 LAB을 두 줄로 배치했습니다. 요청에서 줄 수를 고정하지 않았으므로 줄바꿈 자체를 오류로 보지 않았습니다. 두 이미지 모두 짙은 파란 배경과 제목 아래 흰색 전구 윤곽을 확인할 수 있습니다.

20 steps40 steps
LANGFUSE LAB 포스터 20 steps 원본LANGFUSE LAB 포스터 40 steps 원본

poster_local에서도 20 steps는 한 줄, 40 steps는 두 줄 제목으로 나왔습니다. 후자는 전구가 더 아래에 있습니다. 이 차이가 특정 화면이나 사용 목적에 더 적합한지는 추가 디자인 기준이 있어야 판단할 수 있습니다. 이번 rubric은 요청한 문구·색·전구·여백·가독성 조건에 한정했습니다.

20 steps40 steps
LOCAL IMAGE 포스터 20 steps 원본LOCAL IMAGE 포스터 40 steps 원본

5.2 이번 점수가 설명하는 범위

이번 기준에서는 두 설정의 품질 점수가 같습니다. 그렇다고 시각적으로 완전히 같은 결과이거나, 모든 이미지 요청에서 품질이 같다는 뜻은 아닙니다. 제목·전구 위치·글꼴 굵기가 다르지만 현재 rubric이 두 결과를 모두 허용하는 것입니다. 한 줄 제목이나 특정 위치를 서비스 요구사항으로 삼으려면 생성 전에 그 조건을 prompt와 평가 기준에 추가해야 합니다.

생성 단계 수가 많다는 이유만으로 점수가 더 높아야 하는 것은 아닙니다. 특정 seed와 요청에서 나온 두 이미지는 두 산출물의 비교입니다. 같은 설정을 여러 seed·문구 길이·해상도에서 반복하지 않았다면 모델 전체의 우열로 확대하지 않습니다.

11편의 별도 서버실 일러스트는 좋은 대비 사례입니다. 프롬프트는 랙 4개를 요구했지만 생성 결과에는 5개가 보였습니다. 글자가 없는 그림에는 OCR 문구 점수를 적용할 수 없고, 사물 수처럼 별도의 요구사항을 검사해야 합니다. 이 추가 예제는 위 네 장의 Dataset 점수·채택률에는 포함하지 않았습니다.

운영 평가셋을 키울 때는 쉬운 영어 제목만 반복하기보다 실제 서비스에서 중요한 유형을 추가합니다. 한글·영어 혼합, 숫자·날짜, 긴 제목, 작은 글자, 여러 줄 배치, 특정 색 대비처럼 실패 양상이 다른 요구사항을 포함하면 어떤 변경이 유효했는지 알기 쉽습니다.

시각 검토 점수도 고정된 진실은 아닙니다. 실제 사용자나 디자이너가 같은 rubric으로 일부 이미지를 독립 평가하고, 불일치를 읽어 기준을 다듬어야 합니다. 합성 실습에서 AI 보조 도구가 작성한 점수를 전문가 정답처럼 이름 붙이면 이후 Judge를 검증할 때도 잘못된 확신이 생깁니다.

6. Native 이미지 Judge로 확장

현재 공식 문서에서 Langfuse의 LLM-as-a-Judge는 이미지 등 미디어가 들어 있는 Observation을 평가할 수 있습니다. 평가 프롬프트의 변수를 미디어가 있는 input·output·metadata 필드에 연결하면 Langfuse가 참조를 해석해 선택한 모델에 보냅니다. 모델과 공급자가 그 매체를 지원해야 하며 지원되지 않는 조합은 오류가 됩니다. 멀티모달 Judge 문서

이번 실습에서는 native 이미지 Judge를 구성하거나 실행하지 않았습니다. 로컬 OCR, 규칙 함수, 명시적인 AI 시각 검토를 실행하는 경로와 구분합니다. 유료 비전 API도 호출하지 않습니다.

Native Judge로 확장할 때에는 시각 입력을 지원하는 모델, worker에서 모델에 접근하는 연결, 미디어 전달 방식과 크기 제한을 함께 확인해야 합니다. Self-host에는 LANGFUSE_EVALUATOR_MEDIA_* 설정이 있으며, 평가 실행은 Langfuse worker에서 일어납니다. 브라우저에서 이미지가 보인다는 사실만으로 worker가 같은 이미지를 모델에 전달할 수 있다고 단정할 수 없습니다. Judge 실행 위치와 설정

Judge를 붙인 뒤에도 OCR·규칙 점수를 모두 없애지 않습니다. 픽셀 크기처럼 명확한 조건은 코드가 정확하고 저렴하게 검사할 수 있습니다. 색·구도·의미처럼 시각 해석이 필요한 조건에 Judge를 쓰고, 그 Judge가 틀리는 사례를 별도로 모아 검증하는 편이 원인을 추적하기 쉽습니다.

7. 복사해서 실행하는 최소 예제

아래 공개 예제는 한 사례의 이미지 두 개를 읽어 로컬 OCR을 실행하고 Langfuse Dataset과 두 Experiment를 만듭니다. 11편의 핵심 예제를 parameters["num_inference_steps"]=20으로 실행하면 image-steps20.png가 저장됩니다. 같은 프롬프트·해상도·seed를 유지하고 이 값만 40으로 바꿔 다시 실행하면 image-steps40.png가 저장됩니다. 두 파일을 아래 코드와 같은 작업 폴더에 둡니다. 이 이미지 생성 단계까지 끝나야 평가 예제를 실행할 수 있습니다.

전체 실습은 두 사례 × 두 설정으로 총 네 이미지를 비교합니다. 아래 최소 예제는 그중 한 사례 × 두 설정으로 축약했으며, 시각 검토·PNG CRC·체크포인트·저장 API 대조는 포함하지 않습니다. 최소 예제는 이미지 첨부 → 해상도·OCR 평가 → 실험 비교를 재현하는 범위입니다. 본문의 실측표와 저장 검증 수치는 전체 실습 기록에서 가져오며, 이 코드를 복사한 독자가 같은 수치를 얻는다는 뜻이 아닙니다.

7.1 로컬 OCR 실행

macOS와 Swift 컴파일러가 필요합니다. xcrun --find swiftc로 컴파일러를 확인합니다. 설치돼 있지 않다면 Apple Command Line Tools를 먼저 준비합니다. 아래 내용을 recognize.swift로 저장합니다. 이미지 파일은 로컬 Vision으로 처리하며 외부 모델 API로 보내지 않습니다.

import Foundation
import Vision
import ImageIO
import CryptoKit

let url = URL(fileURLWithPath: CommandLine.arguments[1])
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
      let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
    fatalError("ImageIO could not decode image")
}
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = false
let supported = try request.supportedRecognitionLanguages()
request.recognitionLanguages = ["en-US", "ko-KR"].filter { supported.contains($0) }
try VNImageRequestHandler(cgImage: image, options: [:]).perform([request])
let lines = (request.results ?? []).compactMap { $0.topCandidates(1).first?.string }
let hash = SHA256.hash(data: try Data(contentsOf: url))
    .map { String(format: "%02x", $0) }.joined()
let result: [String: Any] = ["width": image.width, "height": image.height,
    "lines": lines, "sha256": hash, "revision": request.revision]
let data = try JSONSerialization.data(withJSONObject: result, options: [.sortedKeys])
print(String(data: data, encoding: .utf8)!)

컴파일하고 파일별 결과를 저장합니다. JSON의 hash는 다음 단계에서 OCR을 수행한 파일과 업로드할 파일이 같은지 확인할 때 사용합니다.

swiftc recognize.swift -o recognize
./recognize image-steps20.png > image-steps20.ocr.json
./recognize image-steps40.png > image-steps40.ocr.json

7.2 Dataset과 실험 기록

11편과 동일하게 별도로 설치한 Python 3.14로 가상환경을 만듭니다. 이미 11편의 가상환경에서 작업 중이면 생성 명령을 건너뛰고 그 환경을 계속 사용합니다. 아래 python3.14 --version으로 실행 버전을 확인하며, 시스템 기본 python3로 바꾸지 않습니다. Langfuse 프로젝트 키와 접속 주소는 환경변수로 설정합니다. Self-host는 11편에서 이미지 업로드까지 확인한 프로젝트를 사용합니다.

python3.14 --version
python3.14 -m venv .venv
source .venv/bin/activate
python -m pip install langfuse==4.16.0
export LANGFUSE_BASE_URL='http://localhost:3300'
export LANGFUSE_PUBLIC_KEY='pk-lf-프로젝트-공개키'
export LANGFUSE_SECRET_KEY='sk-lf-프로젝트-비밀키'

아래 내용을 같은 폴더의 evaluate_saved.py로 저장합니다. 기본 사례는 poster_langfuse입니다. JSON 파일은 앞에서 실행한 OCR 결과이며 직접 만든 예상 문자열을 넣지 않습니다.

import hashlib
import json
import os
import unicodedata
from pathlib import Path
from uuid import NAMESPACE_URL, uuid5
from langfuse import Langfuse, Evaluation
from langfuse.media import LangfuseMedia

case_id = os.environ.get("IMAGE_CASE", "poster_langfuse")
titles = {"poster_langfuse": "LANGFUSE LAB", "poster_local": "LOCAL IMAGE"}
expected = {"width": 1024, "height": 1024, "required_text": [titles[case_id]]}
paths = {"steps20": Path("image-steps20.png"), "steps40": Path("image-steps40.png")}
ocr_paths = {"steps20": Path("image-steps20.ocr.json"), "steps40": Path("image-steps40.ocr.json")}
measurements, images = {}, {}

def normalize(text):
    return "".join(unicodedata.normalize("NFKC", text).casefold().split())

for variant, path in paths.items():
    raw = path.read_bytes()
    if not raw.startswith(b"\x89PNG\r\n\x1a\n"):
        raise ValueError("PNG required: " + path.name)
    ocr = json.loads(ocr_paths[variant].read_text())
    if hashlib.sha256(raw).hexdigest() != ocr["sha256"]:
        raise ValueError("Image changed after OCR: " + path.name)
    recognized = normalize("\n".join(ocr["lines"]))
    matches = [normalize(text) in recognized for text in expected["required_text"]]
    measurements[variant] = {
        "dimensions_match": [ocr["width"], ocr["height"]] == [1024, 1024],
        "text_coverage": sum(matches) / len(matches),
        "ocr_lines": ocr["lines"], "sha256": ocr["sha256"],
    }
    images[variant] = LangfuseMedia(content_bytes=raw, content_type="image/png")

lf = Langfuse(public_key=os.environ["LANGFUSE_PUBLIC_KEY"],
    secret_key=os.environ["LANGFUSE_SECRET_KEY"],
    base_url=os.environ["LANGFUSE_BASE_URL"])
dataset_name = "image-study/demo-" + case_id
lf.create_dataset(name=dataset_name)
lf.create_dataset_item(dataset_name=dataset_name,
    id=str(uuid5(NAMESPACE_URL, "image-study/public/" + case_id)),
    input={"case_id": case_id, "artifacts": images}, expected_output=expected)
dataset = lf.get_dataset(dataset_name)
if len(dataset.items) != 1 or dataset.items[0].input["case_id"] != case_id:
    raise ValueError("Use a dataset containing only this demo case")

def evaluate(*, output, **kwargs):
    return [
        Evaluation(name="image-dimensions-match", value=float(output["dimensions_match"])),
        Evaluation(name="image-required-text-coverage", value=output["text_coverage"],
                   comment="Local OCR substring coverage; not holistic image quality."),
    ]

for variant in paths:
    def task(*, item, **kwargs):
        return {**measurements[variant], "variant": variant,
                "image": item.input["artifacts"][variant]}
    result = dataset.run_experiment(name="saved-image-" + variant,
        task=task, evaluators=[evaluate], max_concurrency=1,
        metadata={"generation_replayed": False, "variant": variant})
    print(result.format())
lf.flush()
lf.shutdown()
python evaluate_saved.py

poster_local을 평가하려면 그 사례의 두 이미지를 image-steps20.png·image-steps40.png로 준비해 OCR을 다시 실행한 다음 IMAGE_CASE=poster_local python evaluate_saved.py로 실행합니다. 이미지 파일만 바꾸고 예전 OCR JSON을 사용하면 hash 검사에서 중단됩니다.

최소 예제의 숫자는 원본 이미지 파일의 OCR 결과에 따라 달라집니다. 생성 이미지를 눈으로 읽어 위 rubric으로 시각 검토를 추가하고, 각 점수의 근거를 남깁니다. 수치가 모두 1이라고 해서 글자의 가독성·배경·전구·여백까지 검사했다고 해석하지 않습니다.

macOS Vision을 사용하는 OCR 구현이므로 다른 OS에서는 OCR 단계를 해당 환경의 엔진으로 바꿔야 합니다. 이때 엔진·버전·언어·전처리·정규화 조건도 기록합니다. 기존 평가와 조건이 달라졌다면 같은 측정으로 합치지 않고 평가기 버전을 구분합니다.


실습·저장 검증: 2026-10-05. Langfuse OSS v4.50.0, Python SDK 4.16.0, 로컬 Qwen-Image-2.1의 원본 PNG 네 장과 macOS Vision OCR revision 3을 사용했습니다. 같은 두 사례의 20·40 steps 결과를 비교했으며, 각 설정 N=2입니다. 유료 비전 API·native 이미지 Judge·독립 전문가 평가는 실행하지 않았습니다.

이전: 11편 — Qwen 이미지 생성 관측

다음: 13편 — 이미지 생성 비용과 성능

profile
어제보다 더 성장하는 나

0개의 댓글