hOCR 경계 상자를 PDF 페이지에 표시
이 작업은 PDF의 각 페이지를 이미지로 렌더링하고 같은 페이지의 hOCR에서 단어 경계 상자를 읽은 뒤 이미지에 표시합니다. 결과는 페이지 순서의 Media 목록입니다. 렌더링한 이미지나 경계 상자를 표시한 이미지를 업로드하며, 객체에 연결하기 전에는 임시 미디어입니다.
미디어 변환의 Beta API와 버전 조건을 충족해야 합니다. 전체 메타데이터를 읽으므로 TSv2는 @osdk/client 2.56.0 이상을 사용합니다. Python 함수는 beta=True로 선언합니다.
좌표 변환
hOCR의 ocr_page에는 페이지 bbox가, ocrx_word에는 단어 bbox가 들어 있습니다. bbox X1 Y1 X2 Y2는 왼쪽, 위쪽, 오른쪽과 아래쪽 경계입니다. 인식 텍스트와 함께 줄의 경계도 hOCR에 들어갈 수 있으며 아래 구현은 단어 요소를 표시합니다.
페이지 bbox가 (px0, py0, px1, py1), 렌더 이미지가 실제 폭 W와 높이 H라면 다음과 같이 변환합니다.
- 가로 비율 sx = W / (px1 - px0)
- 세로 비율 sy = H / (py1 - py0)
- left = (x1 - px0) × sx
- top = (y1 - py0) × sy
- width = (x2 - x1) × sx
- height = (y2 - y1) × sy
페이지 원점이 0이 아닐 수 있고 가로와 세로 비율도 다를 수 있습니다. 렌더 요청의 폭 1,200만으로 높이를 추정하지 않고 업로드한 이미지의 실제 dimensions를 읽습니다.
가상의 페이지 bbox가 10 20 1010 2020이고 단어가 110 220 210 420, 렌더 이미지가 1,200×1,800이면 표시 상자는 left 120, top 180, width 120, height 180입니다. 이 값은 좌표 변환을 설명하기 위한 입력입니다.
TypeScript hOCR 파서
TSv2 저장소에 cheerio를 설치하고 다음 helper를 src/hocr.ts에 둡니다. HTML을 파싱할 뿐 스크립트를 실행하지 않습니다. 한 페이지 응답에 ocr_page가 정확히 하나 있어야 하며 bbox와 목표 이미지 크기는 유한한 값과 양수 크기를 가져야 합니다.
import { load } from "cheerio";
interface Bbox {
x0: number;
y0: number;
x1: number;
y1: number;
}
export interface HocrWordBox {
label: string;
boundingBox: { $left: number; $top: number; $width: number; $height: number };
}
const BBOX = /(?:^|;)\s*bbox\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)(?=\s*(?:;|$))/;
function readBbox(title: string | undefined): Bbox {
const match = title === undefined ? null : BBOX.exec(title);
if (match === null) throw new Error("hOCR bbox가 없습니다.");
const x0 = Number(match[1]);
const y0 = Number(match[2]);
const x1 = Number(match[3]);
const y1 = Number(match[4]);
if (!Number.isFinite(x0) || !Number.isFinite(y0) || !Number.isFinite(x1) || !Number.isFinite(y1) ||
x1 <= x0 || y1 <= y0) {
throw new Error("hOCR bbox의 좌표 또는 크기가 잘못되었습니다.");
}
return { x0, y0, x1, y1 };
}
export function parseHocrBoundingBoxes(hocr: string, targetWidth: number, targetHeight: number): HocrWordBox[] {
if (!Number.isFinite(targetWidth) || !Number.isFinite(targetHeight) || targetWidth <= 0 || targetHeight <= 0) {
throw new Error("렌더 이미지의 양수 폭과 높이가 필요합니다.");
}
const $ = load(hocr);
const pages = $(".ocr_page");
if (pages.length !== 1) throw new Error("한 페이지의 hOCR가 필요합니다.");
const page = pages.first();
const bounds = readBbox(page.attr("title"));
const sx = targetWidth / (bounds.x1 - bounds.x0);
const sy = targetHeight / (bounds.y1 - bounds.y0);
const boxes: HocrWordBox[] = [];
page.find(".ocrx_word").each((_index, element) => {
const word = $(element);
const label = word.text().trim();
if (label.length === 0) return;
const box = readBbox(word.attr("title"));
boxes.push({
label,
boundingBox: {
$left: (box.x0 - bounds.x0) * sx,
$top: (box.y0 - bounds.y0) * sy,
$width: (box.x1 - box.x0) * sx,
$height: (box.y1 - box.y0) * sy,
},
});
});
return boxes;
}
단어가 없는 페이지는 빈 상자 목록을 반환합니다. 단어가 있는데 bbox가 없거나 잘못되었다면 오류를 내며 임의 좌표를 만들지 않습니다. 이 좌표 변환은 페이지 크기를 맞추는 과정이며 OCR의 인식 오류나 별도 기울기 보정을 추정하여 고치는 알고리즘은 아닙니다.
TypeScript 전체 처리
src/functions/annotatePdfWithOcrBoxes.ts에 작성합니다. 각 페이지의 렌더링과 OCR은 같은 원본을 읽으므로 병렬로 실행합니다. 이후의 업로드, 실제 크기 조회와 표시 작업에는 순서 의존성이 있습니다.
import { transformAndWait, type MediaTransformation } from "@osdk/api/unstable";
import type { Client, Media } from "@osdk/client";
import { uploadMedia } from "@osdk/functions";
import { parseHocrBoundingBoxes } from "../hocr";
export default async function annotatePdfWithOcrBoxes(client: Client, media: Media): Promise<Media[]> {
const transform = client(transformAndWait).transformAndWait;
const document = (await media.fetchFullMetadata?.())?.itemMetadata;
if (document?.type !== "document" || document.format !== "PDF" || document.pages == null) {
throw new Error("페이지 수가 있는 PDF 미디어가 필요합니다.");
}
const pages: Media[] = [];
for (let pageNumber = 0; pageNumber < document.pages; pageNumber++) {
const render: MediaTransformation = {
$documentToImage: {
$encoding: "png",
$operation: { $renderPage: { $pageNumber: pageNumber, $width: 1200 } },
},
};
const ocr: MediaTransformation = {
$documentToText: {
$operation: { $ocrOnPage: {
$pageNumber: pageNumber,
$parameters: { $outputFormat: { $hocr: {} }, $languages: [{ $language: "ENG" }] },
} },
},
};
const [renderResult, ocrResult] = await Promise.all([
transform({ media, transformation: render }),
transform({ media, transformation: ocr, options: { pollTimeoutMs: 120_000 } }),
]);
if (!renderResult.ok || !ocrResult.ok) {
throw new Error(`페이지 ${pageNumber} 렌더링 또는 OCR 실패: ${renderResult.status}, ${ocrResult.status}`);
}
const rendered = await uploadMedia(client, {
data: await renderResult.blob(), fileName: `page-${pageNumber}.png`,
});
const image = (await rendered.fetchFullMetadata?.())?.itemMetadata;
if (image?.type !== "imagery" || image.dimensions === undefined) {
throw new Error(`페이지 ${pageNumber} 렌더 이미지의 크기가 없습니다.`);
}
const boxes = parseHocrBoundingBoxes(await ocrResult.text(), image.dimensions.width, image.dimensions.height);
if (boxes.length === 0) {
pages.push(rendered);
continue;
}
const annotate: MediaTransformation = {
$image: {
$encoding: "png",
$operations: [{ $annotate: { $annotations: boxes.map(box => ({
$geometry: { $boundingBox: box.boundingBox }, $label: box.label,
})) } }],
},
};
const annotated = await transform({ media: rendered, transformation: annotate });
if (!annotated.ok) throw new Error(`페이지 ${pageNumber} 표시 실패: ${annotated.status}`);
pages.push(await uploadMedia(client, {
data: await annotated.blob(), fileName: `page-${pageNumber}-annotated.png`,
}));
}
return pages;
}
단어가 없는 페이지는 이미 렌더링하고 업로드한 이미지를 반환합니다. 표시할 상자가 없으므로 빈 표시 작업을 다시 제출하지 않습니다.
Python hOCR 파서
beautifulsoup4를 설치하고 함수 모듈과 같은 패키지의 hocr.py에 둡니다. 파서는 Foundry 클라이언트 없이 HTML과 목표 이미지 크기만 받습니다.
from dataclasses import dataclass
import math
import re
from bs4 import BeautifulSoup
@dataclass(frozen=True)
class HocrWordBox:
label: str
left: float
top: float
width: float
height: float
BBOX = re.compile(
r"(?:^|;)\s*bbox\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)"
r"\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)(?=\s*(?:;|$))"
)
def read_bbox(title: object) -> tuple[float, float, float, float]:
match = BBOX.search(title) if isinstance(title, str) else None
if match is None:
raise ValueError("hOCR bbox가 없습니다.")
x0, y0, x1, y1 = (float(value) for value in match.groups())
if not all(math.isfinite(value) for value in (x0, y0, x1, y1)) or x1 <= x0 or y1 <= y0:
raise ValueError("hOCR bbox의 좌표 또는 크기가 잘못되었습니다.")
return x0, y0, x1, y1
def parse_hocr_bounding_boxes(hocr: bytes, target_width: int, target_height: int) -> list[HocrWordBox]:
if not math.isfinite(target_width) or not math.isfinite(target_height) or target_width <= 0 or target_height <= 0:
raise ValueError("렌더 이미지의 양수 폭과 높이가 필요합니다.")
soup = BeautifulSoup(hocr, "html.parser")
pages = soup.select(".ocr_page")
if len(pages) != 1:
raise ValueError("한 페이지의 hOCR가 필요합니다.")
page = pages[0]
px0, py0, px1, py1 = read_bbox(page.get("title"))
sx, sy = target_width / (px1 - px0), target_height / (py1 - py0)
boxes: list[HocrWordBox] = []
for word in page.select(".ocrx_word"):
label = word.get_text().strip()
if not label:
continue
x0, y0, x1, y1 = read_bbox(word.get("title"))
boxes.append(HocrWordBox(label, (x0 - px0) * sx, (y0 - py0) * sy, (x1 - x0) * sx, (y1 - y0) * sy))
return boxes
Python 동기 및 비동기 처리
다음 모듈은 위 hocr.py와 같은 패키지에 둡니다. 두 게시 함수는 서로 다른 이름을 사용합니다. 변환 생성 helper는 함수마다 같은 요청 계약을 사용하도록 합니다.
import asyncio
from foundry_sdk.v2.media_sets.models import (
AnnotateImageOperation, Annotation, BoundingBox, BoundingBoxGeometry,
DocumentMediaItemMetadata, DocumentToImageTransformation, DocumentToTextTransformation,
ImageTransformation, ImageryMediaItemMetadata, OcrHocrOutputFormat,
OcrLanguageWrapper, OcrOnPageOperation, OcrParameters, PngFormat, RenderPageOperation,
)
from functions.api import Media, function
from ontology_sdk import FoundryClient
from .hocr import HocrWordBox, parse_hocr_bounding_boxes
def _page_count(document: Media) -> int:
item = document.get_media_full_metadata().item_metadata
if not isinstance(item, DocumentMediaItemMetadata) or item.format != "PDF" or item.pages is None:
raise ValueError("페이지 수가 있는 PDF 미디어가 필요합니다.")
return item.pages
def _render(page_number: int) -> DocumentToImageTransformation:
return DocumentToImageTransformation(
encoding=PngFormat(), operation=RenderPageOperation(page_number=page_number, width=1200),
)
def _ocr(page_number: int) -> DocumentToTextTransformation:
return DocumentToTextTransformation(operation=OcrOnPageOperation(
page_number=page_number,
parameters=OcrParameters(output_format=OcrHocrOutputFormat(), languages=[OcrLanguageWrapper(language="ENG")]),
))
def _boxes(rendered: Media, hocr: bytes) -> list[HocrWordBox]:
item = rendered.get_media_full_metadata().item_metadata
if not isinstance(item, ImageryMediaItemMetadata) or item.dimensions is None:
raise ValueError("렌더 이미지의 실제 크기가 없습니다.")
return parse_hocr_bounding_boxes(hocr, item.dimensions.width, item.dimensions.height)
def _annotation(boxes: list[HocrWordBox]) -> ImageTransformation:
return ImageTransformation(encoding=PngFormat(), operations=[AnnotateImageOperation(annotations=[
Annotation(
geometry=BoundingBoxGeometry(bounding_box=BoundingBox(
left=box.left, top=box.top, width=box.width, height=box.height,
)),
label=box.label,
)
for box in boxes
])])
@function(beta=True)
def annotate_pdf_with_ocr_boxes(document: Media) -> list[Media]:
client = FoundryClient()
reference = document.get_media_reference()
pages: list[Media] = []
for page_number in range(_page_count(document)):
rendered_bytes = client.ontology.media.transform_and_wait(
media_reference=reference, transformation=_render(page_number),
)
hocr = client.ontology.media.transform_and_wait(
media_reference=reference, transformation=_ocr(page_number), poll_timeout_seconds=120.0,
)
rendered = client.ontology.media.upload_media(body=rendered_bytes, filename=f"page-{page_number}.png")
boxes = _boxes(rendered, hocr)
if not boxes:
pages.append(rendered)
continue
annotated = client.ontology.media.transform_and_wait(
media_reference=rendered.get_media_reference(), transformation=_annotation(boxes),
)
pages.append(client.ontology.media.upload_media(
body=annotated, filename=f"page-{page_number}-annotated.png",
))
return pages
@function(beta=True)
async def annotate_pdf_with_ocr_boxes_async(document: Media) -> list[Media]:
client = FoundryClient()
reference = document.get_media_reference()
page_count = _page_count(document)
async def annotate_page(page_number: int) -> Media:
rendered_bytes, hocr = await asyncio.gather(
client.ontology.media.async_transform_and_wait(
media_reference=reference, transformation=_render(page_number),
),
client.ontology.media.async_transform_and_wait(
media_reference=reference, transformation=_ocr(page_number), poll_timeout_seconds=120.0,
),
)
rendered = await client.ontology.media.async_upload_media(
body=rendered_bytes, filename=f"page-{page_number}.png",
)
boxes = _boxes(rendered, hocr)
if not boxes:
return rendered
annotated = await client.ontology.media.async_transform_and_wait(
media_reference=rendered.get_media_reference(), transformation=_annotation(boxes),
)
return await client.ontology.media.async_upload_media(
body=annotated, filename=f"page-{page_number}-annotated.png",
)
return list(await asyncio.gather(*(annotate_page(page_number) for page_number in range(page_count))))
동기 함수는 페이지를 순서대로 처리합니다. 비동기 함수는 모든 페이지를 함께 처리하며 각 페이지의 렌더링과 OCR도 동시에 요청합니다. 전체 메타데이터 조회와 HTML 파싱은 동기 메서드입니다. 큰 문서에서 동시 변환 수와 메모리 사용이 늘어나므로 순차 처리 또는 작업 분할이 필요한지 판단해야 합니다.
실패한 변환이나 파서 오류는 정상 이미지로 대체하지 않고 호출자에게 전파합니다. 이미 완료한 임시 업로드는 함수 오류와 함께 자동으로 롤백되는 온톨로지 편집이 아닙니다. 결과를 영구 보존하려면 객체 미디어 속성에 연결하여 액션을 적용합니다.