Metadata-Version: 2.4
Name: yuma_upright
Version: 0.1.0
Summary: OCR-driven automatic image uprighting (arbitrary-angle deskew) for cropped text regions such as ID card fields, form labels, and document crops.
Author-email: Your Name <you@example.com>
License: MIT
Project-URL: Homepage, https://github.com/yourusername/yuma_upright
Project-URL: Repository, https://github.com/yourusername/yuma_upright
Project-URL: Issues, https://github.com/yourusername/yuma_upright/issues
Keywords: ocr,deskew,image-rotation,easyocr,computer-vision,document-processing,image-preprocessing,id-card,opencv
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Image Processing
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: opencv-python-headless>=4.5
Requires-Dist: numpy>=1.20
Requires-Dist: easyocr>=1.7
Provides-Extra: dev
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Dynamic: license-file

# 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

```bash
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

```python
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
```

```python
import cv2
cv2.imwrite("upright.jpg", result.image)
```

### One-off convenience call (no need to manage an instance)

```python
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 (`str` or `pathlib.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`

```python
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`:

```python
@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 (or `box` region) failed the blur gate.
- `InvalidImageInputError` — the `image` argument couldn't be interpreted.

---

## How it works (short version)

1. OCR finds words + their 4-point boxes.
2. The best word is picked (highest confidence; ambiguous-looking words
   like "SOS"/"NO" are deprioritized since they look the same rotated
   180°).
3. 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.
4. The angle between that direction and horizontal gives the rotation
   needed.
5. 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):

```python
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

- [x] 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 in `0.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

```bash
# 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 (starting
  `pypi-...`) as the password when `twine` prompts, or store it in
  `~/.pypirc`:

  ```ini
  [pypi]
  username = __token__
  password = pypi-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

  [testpypi]
  username = __token__
  password = pypi-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
  ```

- `yuma_upright` as a name must be unique on PyPI — check
  `https://pypi.org/project/yuma_upright/` before your first upload; if
  it's taken you'll need to rename the `name` field in `pyproject.toml`
  (the importable Python package name doesn't have to match).
- Bump `version` in `pyproject.toml` (and `__version__` in
  `src/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`.
