space ocr
가이드아티클요금문서
developer

Python으로 송장 분석: REST API 활용 가이드

Python을 사용하여 송장에서 구조화된 데이터를 추출하는 방법을 알아보세요. 간단한 REST API를 호출하여 송장 이미지로부터 JSON 필드, 품목 리스트, 그리고 검증 가능한 위치 좌표까지 얻는 전체 과정을 안내합니다.

7 분 분량· 2026-08-31

송장에서 정형 데이터를 추출하는 작업은 흔하지만 매우 번거로운 일입니다. 스캔한 영수증이나 거래처 청구서가 담긴 폴더를 열어보면 JPEG나 PNG 파일이 가득하죠. 회계 처리나 데이터 분석을 위해 송장 번호, 총액, 각 라인 아이템을 뽑아내야 합니다. 대부분의 OCR 툴은 텍스트 덩어리만 쏟아낼 뿐, 깨지기 쉬운 정규 표현식으로 일일이 구조를 재조립해야 하는 수고로움이 따릅니다.

더 직접적인 방법이 있습니다. 필요한 구조화된 JSON을 반환하는 REST API를 호출하는 것입니다. 모든 값은 페이지상의 정확한 위치 정보와 함께 제공됩니다. 아래의 인터랙티브 데모에서 최종 결과를 확인해 보세요. 왼쪽 필드를 클릭하면 송장에서 해당 위치가 강조 표시됩니다.

Invoice with extracted-field bounding boxes
Verified fields
Invoice

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.

OCR 처리를 위한 예제 송장
송장 이미지 — /ocr/fields 엔드포인트의 입력값입니다.

space-ocr는 설치형 라이브러리가 아닌, 일반적인 HTTP 서비스입니다. 과정은 간단합니다. 송장 이미지를 POST /ocr/fields 엔드포인트로 보내고, 받고자 하는 데이터의 스키마를 정의하기만 하면 됩니다. invoice_number, total_due 같은 필드와 설명, 가격 등의 열을 가진 line_items 테이블을 지정할 수 있습니다. API는 선언한 스키마 그대로의 JSON을 data.values에 담아 반환하고, 좌표와 검증 결과는 같은 층의 다른 키에 나누어 싣습니다.

추출된 모든 값은 원본 이미지의 바운딩 박스(bounding box)에 연결되어, 검증 가능한 추적 경로를 제공합니다.

응답은 데이터와 검증 결과를 서로 다른 자리에 나누어 돌려줍니다. data.values는 선언한 스키마 그 자체라, invoice_number의 "20250430-001"도 정의한 자리에 그대로 들어옵니다. data.cells는 경로를 키로 쓰는 평평한 맵이고, cells["total_due"]에는 그 값의 지면상 box와 quad, 그리고 verified 판정이 담깁니다. data.review.flagged는 다시 열어볼 만한 경로를 reasons와 함께 나열한 짧은 목록입니다. 아래 스키마는 날짜와 숫자를 선언하므로 data.normalized에 파싱된 값도 함께 오고, values 쪽은 읽어낸 표기를 그대로 유지합니다. 이어지는 Python 스크립트가 이들을 차례로 꺼냅니다.

parse_invoice.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
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})")
✓ 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건의 무료 스캔이 제공됩니다.

  1. API 키 발급받기
    space-ocr에 무료로 가입하고 대시보드 설정에서 API 키를 확인하세요. 키는 'spocr_'로 시작합니다.
  2. 송장 이미지 준비하기
    송장을 JPEG나 PNG와 같은 일반적인 이미지 파일로 저장하세요. 스크립트에서 사용할 파일 경로를 기록해 둡니다.
  3. 데이터 스키마 정의하기
    Python 스크립트에서 추출하려는 필드의 이름과 타입을 정의하는 JSON 배열을 만드세요. 라인 아이템 테이블도 여기에 포함됩니다.
  4. Python 스크립트 작성하기
    requests 라이브러리를 사용하여 이미지를 읽고 base64로 변환한 후, Authorization 헤더에 API 키를 담아 https://api.space-ocr.com/ocr/fields로 POST 요청을 보내는 스크립트를 작성하세요.
  5. 실행 및 JSON 처리하기
    스크립트를 실행하세요. `data.values`는 데이터베이스나 회계 소프트웨어, 분석 도구로 넘기고, `data.review.flagged`에 오른 값만 먼저 확인하시면 됩니다.
SDK나 라이브러리를 설치해야 하나요?
아니요, space-ocr는 표준 HTTP REST 서비스입니다. cURL이나 Python의 requests 라이브러리처럼 HTTP 요청을 보낼 수 있는 모든 언어나 도구에서 호출할 수 있습니다.
PDF 송장도 처리할 수 있나요?
API 엔드포인트는 JPEG, PNG, WebP와 같은 래스터 이미지 형식만 받습니다. PDF 파일을 처리하려면, API로 보내기 전에 코드 내에서 각 페이지를 이미지로 먼저 렌더링해야 합니다. space-ocr 웹 애플리케이션에서는 이 변환 과정을 자동으로 처리해 줍니다.
응답에 포함된 좌표는 무엇인가요?
API는 0-1000으로 정규화된 좌표를 반환합니다. (0,0)은 좌측 상단, (1000,1000)은 우측 하단을 의미합니다. `data.cells`의 각 항목에는 `xmin`·`ymin`·`xmax`·`ymax` 정수 키를 가진 `box`와, 지면의 기울기를 따라가는 네 점의 `quad`가 나란히 들어옵니다. 픽셀로 환산할 때는 판독 시점의 지면 크기를 알려 주는 `data.image`(width / height)를 기준으로 삼으시면 됩니다.
라인 아이템이나 테이블은 어떻게 처리하나요?
`fields` 스키마에서 `type: 'array'`로 필드를 정의하세요. 그런 다음, 중첩된 `children` 속성 안에 각 행에서 추출하고 싶은 열들을 명시하면 됩니다. API는 해당 필드에 대해 객체 배열을 반환할 것입니다.
송장 레이아웃을 미리 알지 못하는 경우에는 어떻게 하나요?
고정된 스키마를 미리 정할 수 없다면, `fields`를 넘기는 대신 요청 본문에 `autoFields: true`를 설정할 수 있습니다. 서비스가 문서 구조를 감지해 찾아낸 필드를 반환합니다. 어떤 이름이 돌아오는지 확인한 뒤에는 그 이름들을 `fields`로 명시해 두면 이후 실행이 일정해집니다.
어떤 값을 사람이 다시 확인해야 하는지 어떻게 아나요?
`data.review.flagged`를 보시면 됩니다. 항목마다 `path`와 랭킹 순으로 정렬된 `reasons` 배열이 들어 있고 0번이 대표 사유입니다. `missing`·`text_mismatch`·`pattern_mismatch` 같은 코드가 지적된 이유를 말해 줍니다. 건수는 `flagged`의 길이 자체이고 별도의 카운터는 없습니다. 값 단위로는 `cells[path].verified`가 같은 판정을 불리언으로 돌려주고, 문자 단위 세부는 `cells[path].evidence`에 담깁니다(`match_ratio`는 0.0~1.0이며 0.85 이상이면 신뢰도 높은 일치로 봅니다). 두 엔진이 같은 오독에 합의한 값은 그대로 통과할 수 있으므로, 이 목록은 육안 확인을 없애는 장치가 아니라 확인 범위를 좁히는 장치입니다.

단 몇 분 안에 송장 파싱을 시작해 보세요.

API 키를 발급받고 매월 100건의 무료 스캔을 이용하세요. 신용카드는 필요 없습니다.

관련 글