Python으로 송장 분석: REST API 활용 가이드
Python을 사용하여 송장에서 구조화된 데이터를 추출하는 방법을 알아보세요. 간단한 REST API를 호출하여 송장 이미지로부터 JSON 필드, 품목 리스트, 그리고 검증 가능한 위치 좌표까지 얻는 전체 과정을 안내합니다.
송장에서 정형 데이터를 추출하는 작업은 흔하지만 매우 번거로운 일입니다. 스캔한 영수증이나 거래처 청구서가 담긴 폴더를 열어보면 JPEG나 PNG 파일이 가득하죠. 회계 처리나 데이터 분석을 위해 송장 번호, 총액, 각 라인 아이템을 뽑아내야 합니다. 대부분의 OCR 툴은 텍스트 덩어리만 쏟아낼 뿐, 깨지기 쉬운 정규 표현식으로 일일이 구조를 재조립해야 하는 수고로움이 따릅니다.
더 직접적인 방법이 있습니다. 필요한 구조화된 JSON을 반환하는 REST API를 호출하는 것입니다. 모든 값은 페이지상의 정확한 위치 정보와 함께 제공됩니다. 아래의 인터랙티브 데모에서 최종 결과를 확인해 보세요. 왼쪽 필드를 클릭하면 송장에서 해당 위치가 강조 표시됩니다.

Each value with a box carries a verified on-page location — in data.cells[path], that is box + 4-point quad + evidence.match_ratio — on a 0–1000 normalized grid (0,0 top-left → 1000,1000 bottom-right), the same shape the live API returns. Hover a field to trace it back to the pixels it came from.

space-ocr는 설치형 라이브러리가 아닌, 일반적인 HTTP 서비스입니다. 과정은 간단합니다. 송장 이미지를 POST /ocr/fields 엔드포인트로 보내고, 받고자 하는 데이터의 스키마를 정의하기만 하면 됩니다. invoice_number, total_due 같은 필드와 설명, 가격 등의 열을 가진 line_items 테이블을 지정할 수 있습니다. API는 선언한 스키마 그대로의 JSON을 data.values에 담아 반환하고, 좌표와 검증 결과는 같은 층의 다른 키에 나누어 싣습니다.
응답은 데이터와 검증 결과를 서로 다른 자리에 나누어 돌려줍니다. data.values는 선언한 스키마 그 자체라, invoice_number의 "20250430-001"도 정의한 자리에 그대로 들어옵니다. data.cells는 경로를 키로 쓰는 평평한 맵이고, cells["total_due"]에는 그 값의 지면상 box와 quad, 그리고 verified 판정이 담깁니다. data.review.flagged는 다시 열어볼 만한 경로를 reasons와 함께 나열한 짧은 목록입니다. 아래 스키마는 날짜와 숫자를 선언하므로 data.normalized에 파싱된 값도 함께 오고, values 쪽은 읽어낸 표기를 그대로 유지합니다. 이어지는 Python 스크립트가 이들을 차례로 꺼냅니다.
import os
import base64
import json
import requests
# Get your API key from an environment variable
API_KEY = os.environ.get("SPACE_OCR_API_KEY")
IMAGE_PATH = "./path/to/your/invoice.jpg" # Path to your invoice image
API_URL = "https://api.space-ocr.com/ocr/fields"
if not API_KEY:
raise ValueError("API key not found. Set the SPACE_OCR_API_KEY environment variable.")
# Declare the schema you want back.
# 'type' adds a deterministic parse under data.normalized; 'required' and 'pattern'
# are checked after extraction and show up in the review list when they fail.
# Declarations never reach the model: they do not change the value, only the signals.
fields_schema = [
{"name": "supplier", "type": "string", "description": "The name of the company that issued the invoice."},
{"name": "invoice_number", "type": "string", "required": True, "pattern": "^[0-9]{8}-[0-9]{3}$"},
{"name": "issue_date", "type": "date", "required": True},
{"name": "total_due", "type": "number", "required": True},
{
"name": "line_items",
"type": "array",
"description": "All items listed in the invoice table.",
"children": [
{"name": "item_description", "type": "string"},
{"name": "unit_price", "type": "number"},
{"name": "quantity", "type": "integer"},
{"name": "line_total", "type": "number"}
]
}
]
# Read the image file and encode it in base64
with open(IMAGE_PATH, "rb") as image_file:
base64_image = base64.b64encode(image_file.read()).decode("utf-8")
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"
}
payload = {
"image": base64_image,
"imageType": "base64",
"fields": fields_schema
}
print(f"Sending request for {IMAGE_PATH}...")
response = requests.post(API_URL, headers=headers, json=payload)
if response.status_code != 200:
# Errors use one envelope: {"error": {"code", "message", "requestId"}, "details": {...}}
try:
err = response.json()["error"]
print(f"HTTP {response.status_code} {err['code']}: {err['message']} (requestId {err['requestId']})")
except (ValueError, KeyError):
# A body over 32MiB is cut off before the app sees it and comes back as HTML
print(f"HTTP {response.status_code}: {response.text[:200]}")
if response.status_code == 429:
print(f"Rate limited - Retry-After: {response.headers.get('Retry-After')} seconds")
# 400 invalid_image is not charged and a retry will not help; 502 is refunded and worth
# retrying; 504 means the 180s sync ceiling was hit, so send that page on its own.
raise SystemExit(1)
data = response.json()["data"]
values = data["values"] # your schema, and nothing else
cells = data["cells"] # flat map: path -> box / quad / verified / review / evidence
review = data["review"] # what is worth a second look
normalized = data.get("normalized", {}) # deterministic parse of the types you declared
image = data["image"] # {"width", "height"} - the frame the coordinates use
print("\n--- Extracted Invoice Data ---")
print(json.dumps(values, ensure_ascii=False, indent=2))
# 'values' keeps the text as it was read; the parsed form lives here.
# A null leaf means the parse failed - the reason sits in cells[path]["normalized"]["error"].
print("\nissue_date parsed:", normalized.get("issue_date"))
print("total_due parsed :", normalized.get("total_due"))
# The review queue: every entry carries a path and a rank-ordered reasons array.
print(f"\n{len(review['flagged'])} of {review['declared']} declared fields were flagged:")
for item in review["flagged"]:
path = item["path"]
reasons = item["reasons"] # reasons[0] is the primary one
cell = cells.get(path) # 'missing' and 'nobox' have no cell to point at
box = cell["box"] if cell else None
print(f" {path}: {reasons[0]} ({', '.join(reasons)}) box={box}")
# Where a single value came from
total = cells.get("total_due")
if total:
print("\ntotal_due")
print(" box :", total["box"]) # {"xmin", "ymin", "xmax", "ymax"}, 0-1000
print(" quad :", total["quad"]) # four {"x", "y"} points, following the page tilt
print(" verified:", total["verified"]) # False if flagged, True if a check ran clean, None if there was nothing to check
print(" evidence:", total["evidence"]) # text_match, source, match_ratio, printed_text ...
left = total["box"]["xmin"] / 1000 * image["width"]
top = total["box"]["ymin"] / 1000 * image["height"]
print(f" pixels : ({left:.0f}, {top:.0f}) on a {image['width']}x{image['height']} page")
# Line items: values holds the rows, cells is keyed by the indexed path
print("\nline_items")
for i, row in enumerate(values.get("line_items") or []):
cell = cells.get(f"line_items[{i}].line_total")
verified = cell["verified"] if cell else None
print(f" row {i}: {row.get('item_description')} x{row.get('quantity')} "
f"= {row.get('line_total')} (verified={verified})")정확도를 지키기 위해 space-ocr는 언어 모델의 출력을 그대로 신뢰하지 않습니다. 모델은 텍스트 값과 함께 페이지에서 어떤 단어를 사용했는지에 대한 힌트를 반환합니다. 그러면 엔진은 추출된 값을 페이지의 실제 OCR 기호와 문자 단위로 대조합니다. 이 대조가 match_ratio 신뢰도 점수(0.85 이상은 높은 신뢰도의 일치로 간주)와 좌표를 만들어 내고, 둘 다 data.cells[path]에 실립니다. box는 xmin·ymin·xmax·ymax 정수 키이고, quad는 지면의 기울기를 따라가는 네 점입니다. 모든 좌표는 0-1000 스케일로 정규화되어 원본 이미지 해상도와 무관하며, 픽셀 환산에는 data.image의 지면 크기를 씁니다. 대조 결과 자체는 evidence.text_match에 담기고, verified는 셀 전체의 판정입니다. 직접 선언한 규칙 위반을 포함해 무엇이든 지적이 서면 false가 됩니다.
API 가격은 필드 수나 페이지 복잡도가 아닌 호출 건당으로 책정됩니다. 이미지당 100원이며, 결과 추출에 실패한 요청은 과금되지 않습니다. 신규 계정에는 매월 100건의 무료 스캔이 제공됩니다.
- API 키 발급받기space-ocr에 무료로 가입하고 대시보드 설정에서 API 키를 확인하세요. 키는 'spocr_'로 시작합니다.
- 송장 이미지 준비하기송장을 JPEG나 PNG와 같은 일반적인 이미지 파일로 저장하세요. 스크립트에서 사용할 파일 경로를 기록해 둡니다.
- 데이터 스키마 정의하기Python 스크립트에서 추출하려는 필드의 이름과 타입을 정의하는 JSON 배열을 만드세요. 라인 아이템 테이블도 여기에 포함됩니다.
- Python 스크립트 작성하기requests 라이브러리를 사용하여 이미지를 읽고 base64로 변환한 후, Authorization 헤더에 API 키를 담아 https://api.space-ocr.com/ocr/fields로 POST 요청을 보내는 스크립트를 작성하세요.
- 실행 및 JSON 처리하기스크립트를 실행하세요. `data.values`는 데이터베이스나 회계 소프트웨어, 분석 도구로 넘기고, `data.review.flagged`에 오른 값만 먼저 확인하시면 됩니다.