space ocr
GuidesArticlesPricingDocs
developer

An OCR API that returns bounding boxes you can verify

Most OCR APIs return bounding boxes, but the coordinate systems differ and a box only says where a value came from. A developer's guide to source coordinates in data.cells[path] — a 0–1000 box, an oriented quad, and the verified and review fields that name the values worth a second look.

8 min read· 2026-08-31

Bounding boxes are how you verify OCR. A bare string tells you what the model thinks it read; a box tells you where on the page it read it, so you (or your reviewer, or your code) can check the value against the original instead of trusting it blind. If you're integrating OCR into anything that gets audited — invoices, expenses, KYC, records management — "the model returned total: 2,045" is not enough; you need to point at the pixels that 2,045 came from.

The good news: most mainstream OCR APIs do return bounding boxes. The catch is that they differ in three ways that matter once you start building — the coordinate system, whether you also get structured fields (not just raw text), and what the per-value signal beside each box actually is. This guide walks through all three, and shows what an OCR API looks like when the coordinates arrive with an explicit review contract: a verdict per value, and a list of the paths worth a second look.

Most OCR APIs return boxes — here's how they differ

Google Cloud Vision, Tesseract, Amazon Textract, and Azure AI Document Intelligence all return geometry with their text. They diverge on the coordinate system, on whether you get structured fields or only raw text + layout, and on what the per-value number reports. The table below summarizes each vendor's public documentation as of August 2026 — these products move, so re-check the current docs before you size the integration work.

APICoordinate systemStructured fieldsPer-value signal
Google Cloud VisionboundingPoly vertices in pixels of the source image (some features return normalizedVertices instead)Text + geometry (structured key-values are Google Document AI, a separate product)Recognition confidence per word/symbol (0–1)
TesseracthOCR / TSV boxes in pixels (a local library, not a hosted API)None — raw text + layoutRecognition confidence per word (0–100)
Amazon TextractBoundingBox normalized 0–1 of page width/height (+ Polygon, also 0–1)Forms/tables via AnalyzeDocument; receipts via AnalyzeExpenseRecognition confidence (%) per block
Azure Document IntelligenceBounding polygon in pixels (images) or inches (PDF)Prebuilt/custom modelsRecognition confidence per word
space-ocrbox normalized 0–1000 plus an oriented quad, keyed by the field path you declaredFields you declare (children for line items), or autoFieldsverified verdict + the review.flagged work list, with evidence behind each

Two things to notice. First, coordinate units aren't portable — pixel boxes are tied to the exact image that was read, while normalized boxes survive a resize. Second, the per-value column isn't the same measurement everywhere: a recognition confidence says how sure an engine is about its own reading, which is a different question from whether the returned value was found on the page at all.

✓ Verified

How the box is derived matters as much as its format. With space-ocr, the language model returns each field's text — and a hint of which word tokens it used — but never the boxes themselves. The engine character-matches that text against the symbols the vision OCR actually detected on the page, so the box lands on the real pixels those characters were found at. Where that cross-check runs, the cell's evidence carries a match_ratio for how much of the value was located; where there was nothing to compare against, the key is simply absent. The token hints can be noisy (they sometimes swap between repeated rows), so column- and row-consistency checks validate them rather than trusting them blindly. That's the difference between a coordinate the model asserts and one that's checked back against the page.

What space-ocr returns for every value

Business data stays in data.values, in exactly the schema you asked for. Everything about where a value came from and whether it held up sits in a parallel map, data.cells, keyed by the same path — total, or items[0].price for a line item. Each cell carries:

  • box — an axis-aligned rectangle { xmin, ymin, xmax, ymax } of integers on a 0–1000 normalized grid (0,0 = top-left, 1000,1000 = bottom-right), independent of the image's pixel size.
  • quad — four ordered points (top-left, top-right, bottom-right, bottom-left) forming an oriented box that follows the document's tilt, so a skewed phone photo still boxes cleanly. It is always returned alongside box.
  • verified — the verdict, and a mirror of review: false whenever anything was flagged, true when nothing was flagged and a check actually ran, null when nothing was flagged but there was nothing to check (a row union, say).
  • reviewnull, or { reasons } with the reasons ranked and the first one primary. Codes include text_mismatch, low_ratio, nobox and missing.
  • evidence — the raw signals behind the verdict: text_match (the character cross-check itself), source (vision_symbol_match, token_id), match_ratio, and printed_text — the glyphs the OCR pass read at those coordinates.

The same paths show up again in data.review.flagged, which is the work list: one entry per value that needs a look, each with its reasons. data.image gives the width and height of the page as it was read, and that is the frame every coordinate is expressed against. (Declare a scalar type, or a pattern or enum on a string field, and a data.normalized layer appears as well — the string-only example below produces none.)

one value: values and cells
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
  "data": {
    "values": { "total": "2,045" },
    "cells": {
      "total": {
        "box": { "xmin": 381, "ymin": 803, "xmax": 500, "ymax": 825 },
        "quad": [
          { "x": 380, "y": 804 }, { "x": 500, "y": 801 },
          { "x": 500, "y": 823 }, { "x": 381, "y": 826 }
        ],
        "verified": true,
        "review": null,
        "evidence": {
          "text_match": true,
          "source": "vision_symbol_match",
          "match_ratio": 1.0,
          "printed_text": "2,045"
        }
      }
    },
    "image": { "width": 1654, "height": 2339 }
  }
}

Pixels or normalized? Convert once, and resizes stop breaking

A recurring OCR-integration bug is that pixel coordinates are tied to the exact image you uploaded — resize or recompress it for storage, or miss an EXIF-rotation flag, and the overlaid boxes drift, crop, or land on the wrong text. Normalized coordinates avoid that whole class of bug: a 0–1000 box maps onto any rendering of the same page.

One thing to get right first: the frame is data.image, not the file you sent. It is the page as it was read — EXIF orientation already baked into the pixels, and a large photo downscaled before reading — so its width and height can come back swapped relative to your upload (send 4000×3000 and you may get 3000×4000). Convert against data.image and the arithmetic holds.

To draw a box on a displayed image, convert once:

  • SVG overlay — give the SVG viewBox="0 0 1000 1000" and draw the box or quad as-is.
  • Absolute-positioned divleftPct = xmin / 1000 * 100, topPct = ymin / 1000 * 100, widthPct = (xmax - xmin) / 1000 * 100, heightPct = (ymax - ymin) / 1000 * 100.
  • Back to pixelspixel_x = box.xmin / 1000 * data.image.width, pixel_y = box.ymin / 1000 * data.image.height.

Because EXIF orientation is already applied to that page, a rotated phone photo (orientation 6/8) doesn't need a correction pass on your side. The page is never deskewed, though: a tilted photo stays tilted, which is why quad follows the tilt while box stays axis-aligned around it.

request fields and get coordinates back
1
2
3
4
5
6
7
8
9
10
11
curl -s https://api.space-ocr.com/ocr/fields \
  -H "Authorization: Bearer $SPACE_OCR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "https://example.com/receipt.jpg",
    "imageType": "url",
    "fields": [
      { "name": "vendor", "type": "string" },
      { "name": "total",  "type": "string" }
    ]
  }'

A confidence score, a match ratio, and a review verdict

This is the distinction worth internalizing. Most OCR APIs document a recognition confidence — a number that reflects how sure the engine is about its own reading, based on things like font clarity and image quality. It's useful, but it's the model grading its own homework. A match ratio measures something external: of the characters in the value the model returned, how many were actually found among the symbols the page-level OCR detected. A value can come back with a healthy recognition confidence and still not line up with anything on the page.

The API doesn't hand you that ratio and leave you to pick a cutoff. match_ratio sits in evidence as the arithmetic behind the verdict; the engine applies the threshold itself — a value counts as a confident character match at 0.85 and above — and when coverage falls short the cell comes back with low_ratio among its review reasons. So the gate in your code is review != null, or better, iterating data.review.flagged directly, which also reaches the classes a ratio cannot see: a required field that never came back at all (missing), a value with no coordinate (nobox), a declared pattern or range the value broke.

One combination surprises people and shouldn't: verified: false together with evidence.text_match: true. That is a value whose characters matched the page fine, caught instead by a rule you declared. Both are worth reviewing, for different reasons — and neither settles the opposite case, since two engines can agree on the same misread.

Verify, then query — without re-running OCR

Coordinates are most useful when the data sticks around. Push images into a sheet with POST /upload, then query it server-side with GET /viewwhere, sort, select, limit, offset — to pull, say, every row where total >= 40000, with no re-OCR and no extra charge. Filters target the sheet's own columns (plus name, ocrStatus and createdAt), and each row comes back with its cells map intact, so box and quad are still there; pass boxes=0 to drop them for a lighter payload. For the verification workflow in depth, see validating OCR with bounding boxes and the OCR audit trail.

Click any value and its source region lights up on the original — the same coordinates the API returns, made interactive.

How to get verifiable bounding boxes from the API

  1. Request fields
    POST the image to /ocr/fields with imageType 'url' or 'base64', and either your own fields array or autoFields set to true. The engine reads raster images.
  2. Read the coordinates
    Look up data.cells by field path. Each cell carries a box { xmin, ymin, xmax, ymax } on a 0–1000 grid, a four-point quad, a verified verdict, review, and evidence.
  3. Overlay or convert
    Draw boxes with an SVG viewBox '0 0 1000 1000', or convert to pixels with pixel_x = box.xmin / 1000 * data.image.width. data.image is the page as it was read, with EXIF rotation already applied.
  4. Work the review queue
    Iterate data.review.flagged instead of thresholding a score yourself: each entry pairs a path with its reasons, and cells[path].evidence holds the supporting detail, match_ratio included.
  5. Store and query
    Push images into a sheet with /upload and query it with GET /view (where, sort, select) — each row keeps its cells map with box and quad, with no re-OCR and no extra charge.
Which OCR APIs return bounding boxes?
Google Cloud Vision, Tesseract, Amazon Textract, and Azure AI Document Intelligence all return geometry with their text, as does space-ocr. Per their public documentation as of August 2026, they differ in the coordinate system (Vision and Tesseract report pixels; Azure uses pixels for images and inches for PDF; Textract normalizes to 0–1; space-ocr uses a 0–1000 grid), in whether structured fields come back or only raw text plus layout, and in what the per-value number reports.
Are the bounding-box coordinates in pixels or normalized?
space-ocr returns a box normalized to a 0–1000 grid, independent of the image's pixel size, plus a four-point quad that follows the page's tilt. Convert to pixels with pixel_x = box.xmin / 1000 * data.image.width (and the same for y), or overlay directly with an SVG viewBox of '0 0 1000 1000'. data.image is the frame — the page as it was read, after EXIF orientation and any downscale — so its width and height can differ from the file you sent.
What's the difference between an OCR confidence score and a match ratio?
A recognition confidence reflects how sure the engine is about its own reading. A match_ratio measures how much of the returned value's text was actually located among the symbols the page-level OCR detected — an external check rather than a self-report. It sits in cells[path].evidence as supporting detail: a value counts as a confident character match at 0.85 and above, and when coverage falls short the engine raises low_ratio in that cell's review reasons, so your code gates on review rather than on the number.
Can I get oriented bounding boxes for skewed or rotated photos?
Yes. Every cell returns a quad of four ordered points (top-left, top-right, bottom-right, bottom-left) alongside the axis-aligned box, and the quad follows the document's tilt. The page is never deskewed, and EXIF orientation is already applied to the page the coordinates are measured against (data.image), so a phone photo with orientation 6 or 8 doesn't need a correction pass on your side.
Does the bounding-box OCR work for Japanese, Korean, and Chinese?
Yes. One engine handles CJK and Latin scripts with automatic language detection — no language parameter to set — and every value comes back under the same contract regardless of script, full-width characters included: box, quad, verified, review, and evidence in data.cells, keyed by the field path you declared.
Related