yuma_upright
OCR-driven automatic image uprighting (arbitrary-angle deskew) for cropped text regions — ID card fields, form labels, single words/lines cut out of a larger document, etc.
Unlike classic "deskew" tools that only fix a few degrees of scanner skew,
yuma_upright reads the actual text with OCR and rotates the image by
whatever angle is needed — 15°, 90°, 180°, 233°, anything — so the text
ends up horizontal, left-to-right, right-side up.
Install
pip install yuma_upright
easyocr (and its own dependency, torch) will be installed automatically.
The first time you construct an Uprighter, EasyOCR will download its
model weights (~a few hundred MB) to ~/.EasyOCR/.
⚠️ Important: this expects a cropped image
yuma_upright OCRs the whole image you give it and anchors the
rotation on the single highest-confidence word it finds. That means:
- Best case: you feed it a tightly cropped single field/word — a name field, a PAN/ID number, a label — with the field's own detector (e.g. your RT-DETR model). This is what the library is designed for and it works reliably.
- Bad case: you feed it a whole, uncropped, multi-field document. It will pick one word anywhere on the page (whichever OCRs best) and rotate the entire image to align just that word — which may be wrong for the rest of the page if different fields are rotated differently, or it may simply pick a word you didn't intend to anchor on.
If you already have your own detector (like the RT-DETR model you
mentioned) that finds field bounding boxes, run detection first, crop to
the box you trust, then pass that crop to yuma_upright — or pass the
box directly via the box= parameter (see below) so yuma_upright skips
its own full-image search and anchors on exactly the region you specify.
Quick start
from yuma_upright import Uprighter
# Construct once, reuse across many images (loads the OCR model once)
up = Uprighter(gpu=False) # set gpu=True if you have CUDA available
result = up.upright("pan_number_crop.jpg")
result.image # np.ndarray (BGR) — the upright image
result.angle_deg # float — rotation actually applied, in degrees
result.axis_word_text # str — the OCR word used to anchor the rotation
result.ocr_words # list[str] — every word OCR found in the crop
result.debug # dict — details of the 180° disambiguation pass
import cv2
cv2.imwrite("upright.jpg", result.image)
One-off convenience call (no need to manage an instance)
from yuma_upright import upright_image
result = upright_image("pan_number_crop.jpg", gpu=False)
Accepted input types
image can be any of:
- a file path (
strorpathlib.Path) - raw encoded image
bytes(e.g. from an upload) - an already-decoded
numpy.ndarray(BGR, RGBA/BGRA, or grayscale) - a
PIL.Image.Image
API reference
class Uprighter
Uprighter(
languages: list[str] = ["en"],
gpu: bool = True,
blur_threshold: float = 15.0,
ocr_min_conf: float = 0.0,
reader=None,
)
| Parameter | Meaning |
|---|---|
languages |
EasyOCR language codes. |
gpu |
Whether EasyOCR should use CUDA. |
blur_threshold |
Laplacian-variance floor; images below this are rejected as too blurry to OCR reliably. Lower it if your inputs are naturally soft; raise it to reject marginal images earlier. |
ocr_min_conf |
Minimum per-word OCR confidence to keep. Default 0.0 (blur gate does the filtering instead). |
reader |
Pass an already-built easyocr.Reader to reuse across multiple Uprighter instances / avoid re-loading weights. |
Uprighter.upright(image, box=None, resolve_ambiguity=True, invert_direction=False) -> UprightResult
| Parameter | Meaning |
|---|---|
image |
Any supported input type (see above). Should be a single cropped field/region — see the warning above. |
box |
(Roadmap feature, partially available now) Skip full-image OCR search and anchor the rotation on a specific region instead. Accepts either an axis-aligned (x1, y1, x2, y2) box, or a 4-point polygon [[x,y],[x,y],[x,y],[x,y]] (top-left, top-right, bottom-right, bottom-left — same convention OCR/detector boxes typically use). Use this if you already have a detector (e.g. your own RT-DETR model) telling you exactly which region to trust as the orientation reference. |
resolve_ambiguity |
Whether to run the 180°-disambiguation OCR pass. True by default. Turn off for speed if you don't care about upside-down results. |
invert_direction |
If your outputs consistently rotate the "wrong way" for your data/box convention, set True to flip the sign of the computed angle without touching anything else. See "Rotation direction" below. |
Returns an UprightResult:
@dataclass
class UprightResult:
image: np.ndarray # rotated (upright) BGR image
angle_deg: float # final rotation actually applied
raw_angle_deg: float # angle before 180° disambiguation
axis_word_text: str | None # OCR text used as the axis anchor
orientation_used: str | None # "left-right-ends" or "top-bottom-ends"
ocr_words: list[str] # all words seen during OCR
debug: dict # 180°-disambiguation candidate details
upright_image(image, gpu=True, languages=None, **kwargs) -> UprightResult
Module-level convenience wrapper around a lazily-shared Uprighter
instance. Good for scripts / one-offs. For processing many images,
construct your own Uprighter once and call .upright() in a loop instead
— it avoids the overhead of re-checking whether the shared instance's
config matches on every call.
Exceptions
All in yuma_upright.exceptions, all subclasses of UprightError:
NoTextFoundError— OCR found nothing usable in the image.ImageTooBlurryError— image (orboxregion) failed the blur gate.InvalidImageInputError— theimageargument couldn't be interpreted.
How it works (short version)
- OCR finds words + their 4-point boxes.
- The best word is picked (highest confidence; ambiguous-looking words like "SOS"/"NO" are deprioritized since they look the same rotated 180°).
- Each word box has two edge-pairs —
(top, bottom)and(left, right). Whichever pair is shorter marks the two ends of the word; their midpoints define the reading direction, regardless of the word's rotation. - The angle between that direction and horizontal gives the rotation needed.
- Because geometry alone can't tell which end is the start of the word, the result can be off by exactly 180°. This is resolved by rotating a crop both ways, OCR-ing each, and keeping the orientation with higher mean OCR confidence (upside-down text reliably OCRs worse).
Rotation direction
The sign convention assumes EasyOCR's box point order (top-left,
top-right, bottom-right, bottom-left) and a standard image coordinate
system (y grows downward). If you're feeding in box= polygons from a
different detector/convention and results consistently rotate opposite to
what you expect, pass invert_direction=True rather than modifying the
angle math yourself.
About the RT-DETR field-detection model
The original prototype this library is based on used a custom-trained
RT-DETR model (best_pan_details.pt) to find PAN card fields (name,
father's name, PAN number, DOB) before cropping and uprighting each one.
That detector is intentionally NOT part of this package. It's a
domain-specific (PAN card) object-detection model with its own weights
file, license considerations, and inference dependencies (ultralytics) —
bundling it would make yuma_upright heavy and narrow instead of a general
OCR-uprighting utility. Recommended pattern if you have your own detector
(RT-DETR, YOLO, or anything else):
from ultralytics import RTDETR
from yuma_upright import Uprighter
detector = RTDETR("best_pan_details.pt")
up = Uprighter(gpu=True)
results = detector.predict(image_rgb, conf=0.45, iou=0.45)
for box in results[0].boxes:
x1, y1, x2, y2 = box.xyxy[0].cpu().numpy().astype(int)
field_crop = original_bgr[y1:y2, x1:x2]
result = up.upright(field_crop)
# result.image is now the upright crop for this field
If there's demand, a separate optional package (e.g. yuma_upright[pan]
or a standalone yuma_upright_pan_detector) could wrap this pattern —
open an issue if you want that.
Roadmap / planned improvements
- Accept a caller-supplied bounding box / polygon (
box=) to anchor rotation on a known region instead of always searching the whole image (basic support shipped in0.1.0). - Accept multiple
box=regions + a policy for combining/voting on their angles (useful for multi-line fields). - Optional non-OCR 180° disambiguation (e.g. a lightweight upright/ upside-down image classifier) as a faster alternative to the current double-OCR pass.
- Batch API (
upright_many([...])) that shares one OCR reader efficiently across a list of crops. - Pluggable OCR backend (currently hard-wired to EasyOCR).
Development / publishing this package to PyPI
# from the repo root (where pyproject.toml lives)
python -m pip install --upgrade build twine
# 1. Build sdist + wheel into dist/
python -m build
# 2. (Recommended) upload to TestPyPI first and verify install works
python -m twine upload --repository testpypi dist/*
pip install --index-url https://test.pypi.org/simple/ yuma_upright
# 3. Upload to real PyPI
python -m twine upload dist/*
Notes:
-
You need a PyPI account and an API token (Account settings → API tokens). Use
__token__as the username and the token (startingpypi-...) as the password whentwineprompts, or store it in~/.pypirc:[pypi] username = __token__ password = pypi-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx [testpypi] username = __token__ password = pypi-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
-
yuma_uprightas a name must be unique on PyPI — checkhttps://pypi.org/project/yuma_upright/before your first upload; if it's taken you'll need to rename thenamefield inpyproject.toml(the importable Python package name doesn't have to match). -
Bump
versioninpyproject.toml(and__version__insrc/yuma_upright/__init__.py) before every re-upload — PyPI does not allow re-uploading the same version number, even after deleting it.
License
MIT — see LICENSE.
Metadata
Release files for yuma-upright 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| yuma_upright-0.1.0.tar.gz | 20.2 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| yuma_upright-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 36.5 kB
Release files / yuma_upright-0.1.0.tar.gz
| Download URL | yuma_upright-0.1.0.tar.gz |
|---|---|
| Size | 20.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3aa9866333e376c0092dc60197249ced3757590e0731ef6a7b9cf5b150f11e0f
|
|
BLAKE2b-256 checksum How to use checksums |
934e225814a56272d8670fc74fb833978feb1a2293282330e3c70d637b60d7ac
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.10
|
Release files / yuma_upright-0.1.0-py3-none-any.whl
| Download URL | yuma_upright-0.1.0-py3-none-any.whl |
|---|---|
| Size | 16.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bbff8e763622d05f0427e0207cf31c1e6d93613592ab7ee7692c622526648a3c
|
|
BLAKE2b-256 checksum How to use checksums |
26a2ecdb8f62505b3419a67959ff9a6163cf09ca8496ec1f83b65278079b54b1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.10
|
Security for yuma-upright 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.