Native OCR library using platform-specific frameworks (macOS Vision, Windows Runtime OCR)
Project description
natocr
natocr (native ocr) is a small Python wrapper around the OCR engines that already ship with macOS and Windows: Vision framework on macOS and Windows Runtime OCR on Windows.
These built-in engines are generally faster, more efficient, and more accurate than third-party alternatives like Tesseract. natocr makes reaching for them painless via one clean Python API instead of wrangling with Objective-C bridges or WinRT async plumbing.
Notable Updates
- v2.1.0 (2026-06-25) - confidence helpers and line + paragraph aggregation
- v2.0.0 (2026-06-22) - batch & non-blocking async support
- v1.6.1 (2026-06-04) - animated PNG and multi-image HEIF support
- v1.6.0 (2026-06-04) - multi-page documents and DjVu support
- v1.5.0 (2026-06-04) - JPEG 2000, JPEG XL, and JPEG XR / HD Photo support
- v1.4.0 (2026-06-04) - HEIC / HEIF (iPhone photo) support
Install
pip install natocr
pip install natocr[extras] # for JPEG XL, JPEG XR & DjVu support
The right native backend (Vision on macOS, Windows Runtime OCR on Windows) is pulled in automatically for your platform - no OS-specific install command to pick.
natocr ships a py.typed marker, so the public API is fully typed - mypy,
pyright, and your editor pick up the hints with no stubs needed.
Quick start
from natocr import OCR
ocr = OCR() # defaults to english
pages = ocr.recognize("invoice.png") # one OCRResult per page
print(pages[0].text)
Invoice #1042 Total $58.20 Thank you!
recognize() always returns a list of OCRResult - one per page. Most images
are a single page, so you'll often just read pages[0]; multi-page/multi-frame
inputs (DjVu, TIFF, GIF, animated PNG, multi-image HEIC/HEIF) give one result per
frame (see Multi-page documents).
Examples
Lines and paragraphs
from natocr import OCR
ocr = OCR()
page = ocr.recognize("cosmos.png")[0]
for line in page.lines: # plain strings, top to bottom
print(line)
for para in page.paragraphs: # blocks, with averaged confidence
print(round(para.confidence or 0, 2), para.text)
We are made of star stuff.
0.97 We are made of star stuff.
Multi-page documents
# one result per page - tiff, djvu, animated gif/png, multi-image heic
for i, page in enumerate(ocr.recognize("pale-blue-dot.tiff"), start=1):
print(f"--- page {i} ---")
print(page.text)
Batch processing
# many files at once, bounded concurrency, results stay in input order
shots = ["cosmos-01.png", "cosmos-02.png", "cosmos-03.png"]
for pages in ocr.recognize_many(shots, max_concurrency=4):
print(pages[0].text)
Async (non-blocking)
import asyncio
from natocr import OCR
ocr = OCR()
async def main():
# offloaded to a worker thread, so the event loop keeps moving
page = (await ocr.arecognize("contact.png"))[0]
print(page.text)
# or a whole batch concurrently
pages = await ocr.arecognize_many(["frame1.png", "frame2.png"])
print([p[0].text for p in pages])
asyncio.run(main())
PDFs
natocr currently reads images, not PDFs - rasterize each page first (here with
pymupdf), then hand the frames straight to
recognize_many():
import fitz # pymupdf
from natocr import OCR
ocr = OCR()
# render each pdf page to png bytes at 200 dpi
doc = fitz.open("pale-blue-dot.pdf")
rendered = [p.get_pixmap(dpi=200).tobytes("png") for p in doc]
# scan them all in one bulk call
for pages in ocr.recognize_many(rendered):
print(pages[0].text)
Confidence Scores and Bounding Boxes
Beyond the flat .text, each OCRResult carries a per-detection breakdown with
bounding boxes and (on macOS) confidence scores:
page = ocr.recognize("receipt.png")[0] # first (often only) page
print(page.confidence) # avg confidence, or None
for element in page.elements:
box = element.bounds.bounds # (x, y, width, height) in pixels
print(f"{element.text!r} @ {box} conf={element.confidence}")
0.93
'Carl Sagan' @ (24.0, 18.0, 180.0, 32.0) conf=0.97
'Chilly Willy' @ (24.0, 70.0, 96.0, 28.0) conf=0.95
'$420' @ (220.0, 70.0, 80.0, 28.0) conf=0.88
Lines and Words
There's also convenience views for grouping a page by reading order:
page.lines # ['Carl Sagan', 'Chilly Willy $240'] - grouped into lines
page.words # list of TextElement with non-empty text
Want the confidence and bounds for each line (not just the text)? text_lines
gives you the same grouping as TextLine
objects, and paragraphs merges lines into blocks by their vertical gaps - both
aggregate confidence across their elements:
for line in page.text_lines:
print(line.text, line.confidence, line.bounds.bounds)
for para in page.paragraphs: # lines joined by newlines, confidence averaged
print(para.confidence, para.text)
Filtering by Confidence
Drop the low-confidence noise with filter() - it hands back a new OCRResult
keeping only detections at or above the threshold:
clean = page.filter(0.8) # only elements >= 0.8 confidence
print(clean.text)
Elements without a confidence score (Windows OCR doesn't report one) are kept by
default since they can't be judged - pass drop_unknown=True to drop them too.
Detection Language
Pick a different recognition language, and inspect what the current platform supports:
ocr = OCR(language="fr")
print(ocr.platform) # 'darwin' or 'win32'
print(ocr.supported_languages) # ['en-US', 'fr-FR', 'de-DE', ...]
The supported set is decided by the OS and queried live, so
supported_languages always reflects the current machine. On macOS it's
Vision's built-in set for your macOS version; on Windows it's whatever OCR
language packs are installed. See the Usage guide
for the full list and how to add Windows language packs.
Alternative Inputs
recognize() accepts more than file paths - hand it whatever you already have
in memory:
from PIL import Image
import numpy as np
ocr.recognize("page.png") # a file path
ocr.recognize(Image.open("page.png")) # a PIL image
ocr.recognize(np.array(image)) # a numpy array (e.g. from OpenCV)
ocr.recognize(open("page.png", "rb").read()) # raw image bytes
Batch and async
recognize() handles one input at a time. For bulk jobs, recognize_many()
runs many inputs concurrently with bounded parallelism. The native engines
release the GIL while recognizing, so this gives real throughput instead of
plodding through the list one by one:
paths = ["page1.png", "page2.png", "page3.png"]
results = ocr.recognize_many(paths, max_concurrency=4)
for pages in results: # one entry per input, in the same order
print(pages[0].text) # each entry is a list of pages, like recognize()
recognize_many() accepts the same input types as recognize() (paths, PIL
images, numpy arrays, bytes - mix and match), preserves input order, and
defaults max_concurrency to the CPU count.
There are also awaitable variants so OCR never blocks your event loop - drop them straight into FastAPI or any async server:
result = await ocr.arecognize("page.png") # one input
results = await ocr.arecognize_many(paths) # many, concurrently
arecognize() / arecognize_many() offload the blocking native call to a
worker thread, so the calling coroutine stays responsive.
Supported File Types
Images are decoded with Pillow, so any raster
format Pillow can open works as an input file or byte string. HEIC/HEIF decoding
(and AVIF) is provided by the bundled pillow-heif,
so iPhone photos work with no extra setup. JPEG XL, JPEG XR, and DjVu need extra
decoders - install them with pip install natocr[extras] (see
Optional formats below).
| Format | Extensions | Notes |
|---|---|---|
| AVIF | .avif |
AV1-based, decoded via the bundled pillow-heif |
| BMP | .bmp |
uncompressed bitmap |
| DjVu | .djvu, .djv |
scanned documents; multi-page (needs natocr[extras] + the djvulibre system library) |
| GIF | .gif |
multi-frame - one result per frame |
| HEIC/HEIF | .heic, .heif, .hif |
iPhone photos and screenshots; multi-image containers give one result per image |
| JPEG | .jpg, .jpeg |
great for photos of documents |
| JPEG 2000 | .jp2, .j2k, .jpf, .jpx |
wavelet-based, decoded natively by Pillow |
| JPEG XL | .jxl |
modern successor to JPEG (needs natocr[extras]) |
| JPEG XR / HD Photo | .jxr, .wdp, .hdp |
Microsoft HD Photo (needs natocr[extras]) |
| PCX | .pcx |
legacy PC Paintbrush, common in old scan archives |
| PNG | .png |
recommended - lossless; animated PNG gives one result per frame |
| PPM/PGM | .ppm, .pgm |
netpbm bitmaps |
| TIFF | .tif, .tiff |
common for scans; multi-page |
| WebP | .webp |
modern lossy/lossless |
Optional formats (JPEG XL, JPEG XR, DjVu)
These are optional because their decoders are extra dependencies. Install them with:
pip install natocr[extras]
That pulls in pillow-jxl-plugin
for .jxl, imagecodecs for
.jxr/.wdp/.hdp, and python-djvulibre
for .djvu/.djv. Once installed they decode through the same recognize()
call as every other format - no extra code. Without the extra, the rest of the
formats above (including JPEG 2000) keep working unchanged.
DjVu also needs the system djvulibre library that python-djvulibre builds
against:
brew install djvulibre # macOS
sudo apt install libdjvulibre-dev # Debian/Ubuntu
On Windows, install DjVuLibre so its DLLs land
on PATH (the wheel links against it).
[!NOTE] Support degrades gracefully: if
natocr[extras]or thedjvulibrelibrary isn't present, DjVu just isn't registered and opening a.djvuraises Pillow's usualUnidentifiedImageError. Every other format keeps working - nothing else breaks.
Multi-page documents
recognize() reads every page and returns one OCRResult per page, in
order. The formats that can carry more than one frame/page are DjVu,
multi-page TIFF, animated GIF, animated PNG, and multi-image
HEIC/HEIF:
for i, page in enumerate(ocr.recognize("scan.djvu"), start=1):
print(f"--- page {i} ---")
print(page.text)
Single-page inputs (PNG, JPEG, ...) come back as a one-element list, so the same
loop works for everything - or just grab recognize(...)[0].
In addition to file paths, recognize() accepts these in-memory types:
| Input type | Example |
|---|---|
str (file path) |
ocr.recognize("page.png") |
PIL.Image.Image |
ocr.recognize(Image.open("page.png")) |
numpy.ndarray |
ocr.recognize(np.array(image)) |
bytes (encoded image) |
ocr.recognize(data) |
[!NOTE] Only DjVu, TIFF, GIF, animated PNG, and multi-image HEIC/HEIF carry multiple pages here. PDFs aren't decoded directly - rasterize a page to one of the formats above first (e.g. with
pdf2imageorpymupdf).
Testing
Install the dev dependencies (in a virtualenv), then run the suite. The tests mock the native macOS Vision and Windows Runtime backends, so they run anywhere without those frameworks installed.
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
Run everything with coverage (coverage is wired up in pyproject.toml, so plain
pytest already reports it):
pytest
Other handy invocations:
# run a single test file
pytest tests/test_models.py
# run one test by name
pytest -k test_lines_groups_close_y_into_single_line
# verbose output
pytest -v
Coverage reports land in the terminal, in htmlcov/index.html, and in
coverage.xml.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file natocr-2.1.1.tar.gz.
File metadata
- Download URL: natocr-2.1.1.tar.gz
- Upload date:
- Size: 31.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e1fe09d87b51e228bc012a43b73a0a2af2e4bbfe4b143df34f7c64515f95b267
|
|
| MD5 |
dcd159e66b8bf5482837479fa1741104
|
|
| BLAKE2b-256 |
a59d34d191a9c249f25d32072027f5b9edf76f6f554c0d09930f8c74992f8689
|
File details
Details for the file natocr-2.1.1-py3-none-any.whl.
File metadata
- Download URL: natocr-2.1.1-py3-none-any.whl
- Upload date:
- Size: 19.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2515cf70146a2fef07cc3136e7bba862c066a7f9704eb417d4ad042b9f72862f
|
|
| MD5 |
b70901abb893318d990c89f2e99a2223
|
|
| BLAKE2b-256 |
8c8c86333396334224e9096e257de7d46e70133f2467504a98ab53d8477b25cb
|