azerbaijani-ocr / app.py
multimodalart's picture
multimodalart HF Staff
Upload folder using huggingface_hub
bfcdf0a verified
Raw History Blame Contribute Delete
13.5 kB
"""Azerbaijani line & page OCR — CRNN + CTC recogniser (LocalDoc/azerbaijani-ocr-crnn).
Two modes:
* Line — the recogniser alone on a cropped line (the model's native input).
* Page — PP-OCRv5 detection finds the lines, the recogniser reads them.
Vendored from the model repo (CC-BY-4.0): `modeling.py` and the detection /
reading-order logic of `pipeline.py`.
"""
import os
# Must precede the torch import: allocator fragmentation under the transient
# pressure of many wide crops (see CUDACachingAllocator NVML assert).
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
os.environ.setdefault("FLAGS_use_mkldnn", "0") # oneDNN clashes with the PIR executor
os.environ.setdefault("GLOG_minloglevel", "2")
import spaces # noqa: F401 — must precede any CUDA-touching import
import tempfile
import time
import threading
import numpy as np
import torch
from PIL import Image
from huggingface_hub import hf_hub_download
import gradio as gr
from modeling import load, preprocess, ctc_greedy, HEIGHT, DOWNSAMPLE
MODEL_ID = "LocalDoc/azerbaijani-ocr-crnn"
DET_MODEL = "PP-OCRv5_server_det"
UNCLIP = 1.5 # box inflation; raise if ç ş ğ descenders get clipped
PAD_FRAC = 0.12 # vertical headroom for descenders
MAX_WIDTH = 1200
BATCH = 12
# --------------------------------------------------------------------------- model
_path = hf_hub_download(MODEL_ID, "model.pt")
_model, _itos = load(_path, "cuda") # eager .to("cuda"); ZeroGPU packs it at startup
_model.eval()
print(f"recogniser ready (training={_model.training})", flush=True)
# ------------------------------------------------------------------- detector (lazy)
_det = None
_det_lock = threading.Lock()
def _detector():
"""PP-OCRv5 detector, built once and reused by the warm worker.
Constructed lazily: `import spaces` has already run by the time this is
first called, so importing paddle inside a `@spaces.GPU` call is safe and
paddle never becomes a hard module-scope dependency.
"""
global _det
if _det is None:
with _det_lock:
if _det is None:
from paddleocr import TextDetection
_det = TextDetection(model_name=DET_MODEL, enable_mkldnn=False,
unclip_ratio=UNCLIP)
return _det
# Warm the detector's weights into the image cache at build/startup time, so a
# cold GPU worker doesn't spend its first request downloading them. Paddle is a
# CPU build, so this does not initialise CUDA. Non-fatal if it fails: page mode
# then pays the download once on its first call.
try:
_detector()
print("PP-OCRv5 detector ready", flush=True)
except Exception as _e: # noqa: BLE001
print(f"detector pre-warm skipped ({_e!r}); will build lazily", flush=True)
# ------------------------------------------------------- reading order (from pipeline.py)
def _reading_order(boxes):
"""Cluster boxes into rows by y, then left to right within each row."""
if not boxes:
return []
arr = [np.array(b) for b in boxes]
tol = max(6.0, float(np.median([np.ptp(b[:, 1]) for b in arr])) * 0.6)
idx = sorted(range(len(arr)), key=lambda i: arr[i][:, 1].mean())
rows, cur = [], [idx[0]]
for i in idx[1:]:
if abs(arr[i][:, 1].mean() - arr[cur[-1]][:, 1].mean()) <= tol:
cur.append(i)
else:
rows.append(cur)
cur = [i]
rows.append(cur)
out = []
for row in rows:
out += sorted(row, key=lambda i: arr[i][:, 0].min())
return [boxes[i] for i in out]
def _pad(boxes, height):
"""Pad above and below, never more than half the gap to the neighbouring line."""
order = sorted(range(len(boxes)), key=lambda i: boxes[i][1])
out = list(boxes)
for k, i in enumerate(order):
x0, y0, x1, y1 = boxes[i]
want = (y1 - y0) * PAD_FRAC
up = y0 - boxes[order[k - 1]][3] if k > 0 else want * 2
dn = boxes[order[k + 1]][1] - y1 if k + 1 < len(order) else want * 2
out[i] = (x0, max(0.0, y0 - min(want, max(0.0, up) * 0.45)),
x1, min(float(height), y1 + min(want * 1.4, max(0.0, dn) * 0.45)))
return out
def _forward(imgs):
"""One forward pass on a list of HxW grayscale crops -> list of T x C arrays.
Each crop is decoded from its own T columns only, so sub-batches may pad to
different widths and still be merged as a plain list.
"""
W = max(im.width for im in imgs)
W = int(np.ceil(W / DOWNSAMPLE) * DOWNSAMPLE)
x = torch.ones(len(imgs), 1, HEIGHT, W)
for k, im in enumerate(imgs):
a = (np.array(im, dtype=np.float32) / 255.0 - 0.5) / 0.5
x[k, 0, :, :im.width] = torch.from_numpy(a)
with torch.no_grad():
ids = _model(x.to("cuda")).cpu().argmax(-1).numpy()
return [ids[k][:max(1, im.width // DOWNSAMPLE)] for k, im in enumerate(imgs)]
def _batched(part):
"""Recognise a batch, halving it if the GPU refuses the allocation.
A very wide batch can exhaust the worker's memory pool (the CUDA caching
allocator raises an NVML internal assert rather than a clean OOM); retrying
with smaller sub-batches keeps a big page from failing outright.
"""
try:
return _forward(part)
except RuntimeError:
if len(part) == 1:
raise
mid = len(part) // 2
return _batched(part[:mid]) + _batched(part[mid:])
def _recognise(crops):
"""Batched recognition (width-sorted so each batch pads to one width)."""
texts = []
order = sorted(range(len(crops)), key=lambda i: crops[i].width)
for s in range(0, len(order), BATCH):
part = order[s:s + BATCH]
rows = _batched([crops[i] for i in part])
for k, i in enumerate(part):
prev, s_ = -1, []
for c in rows[k]:
if c != prev and c != 0:
s_.append(_itos[c])
prev = c
texts.append((i, "".join(s_)))
return [t for _, t in sorted(texts)]
def _detect_polys(image):
"""Run the detector via a unique temp file (no fixed path: handlers run concurrently)."""
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as fh:
image.convert("RGB").save(fh.name)
tmp = fh.name
try:
polys = []
for res in _detector().predict(tmp):
d = res if isinstance(res, dict) else (getattr(res, "json", {}) or {}).get("res", {})
polys += [np.array(p).reshape(-1, 2) for p in d.get("dt_polys", [])]
return polys
finally:
try:
os.remove(tmp)
except OSError:
pass
# ------------------------------------------------------------------------ inference
@spaces.GPU(duration=25)
def read_line(image: Image.Image, pad: int = 8) -> str:
"""Read a single cropped line of Azerbaijani text (Latin or Cyrillic script).
Args:
image: a crop containing exactly one line of text, dark text on a light background.
pad: extra white margin, in pixels, wrapped around the crop before recognition.
"""
if image is None:
raise gr.Error("Upload an image of one text line first.")
img = image.convert("L")
pad = max(0, int(pad))
if pad:
canvas = Image.new("L", (img.width + 2 * pad, img.height + 2 * pad), 255)
canvas.paste(img, (pad, pad))
img = canvas
with torch.no_grad():
logits = _model(preprocess(img).to("cuda"))
return ctc_greedy(logits.cpu(), _itos)
@spaces.GPU(duration=60)
def read_page(image: Image.Image, col: str = "single",
join_hyphens: bool = False) -> str:
"""Read a whole page scan: detect the lines, then recognise each one.
Reading order assumes a single text column. For a two-column page, crop the
left half in "Left column" mode and the right half in "Right column" mode
(the columns are then read in the right order).
Args:
image: a page scan, roughly upright, at least ~150 dpi.
col: "single" for a one-column page, "left" or "right" to isolate one column of a two-column page.
join_hyphens: reattach words broken across a line break.
"""
if image is None:
raise gr.Error("Upload a page image first.")
img = image.convert("RGB")
if col == "left":
img = img.crop((0, 0, img.width // 2, img.height))
elif col == "right":
img = img.crop((img.width // 2, 0, img.width, img.height))
polys = _detect_polys(img)
if not polys:
return "(no text detected)"
polys = _reading_order(polys)
boxes = [(float(p[:, 0].min()), float(p[:, 1].min()),
float(p[:, 0].max()), float(p[:, 1].max())) for p in polys]
boxes = _pad(boxes, img.height)
crops = []
for x0, y0, x1, y1 in boxes:
if (y1 - y0) >= (x1 - x0): # vertical text, not a line
continue
c = img.convert("L").crop((int(x0), int(y0), int(x1), int(y1)))
if c.width < 8 or c.height < 8:
continue
r = HEIGHT / c.height
c = c.resize((max(8, int(c.width * r)), HEIGHT), Image.LANCZOS)
if c.width > MAX_WIDTH:
c = c.crop((0, 0, MAX_WIDTH, HEIGHT))
crops.append(c)
lines = _recognise(crops)
if not join_hyphens:
return "\n".join(lines)
out = []
for ln in lines:
if out and out[-1].endswith("-"):
out[-1] = out[-1][:-1] + ln.lstrip()
else:
out.append(ln)
return "\n".join(out)
# ------------------------------------------------------------------------------- UI
CSS = """
#col-container { max-width: 1100px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""
with gr.Blocks(title="Azerbaijani OCR") as demo:
with gr.Column(elem_id="col-container"):
gr.Markdown(
"# Azerbaijani OCR — CRNN + CTC\n"
"An 8.5M-parameter recogniser for Azerbaijani print, **Latin and Cyrillic** "
"script. It reads one line at a time; page mode pairs it with a text "
"detector to do whole scans.\n\n"
"[model card](https://huggingface.co/LocalDoc/azerbaijani-ocr-crnn) · "
"[benchmark](https://huggingface.co/datasets/LocalDoc/azerbaijani-ocr-benchmark) "
"(CER 0.0242 / WER 0.0565)"
)
with gr.Tabs():
with gr.Tab("Line"):
with gr.Row():
with gr.Column(scale=1):
line_in = gr.Image(type="pil", label="One text line",
sources=["upload", "clipboard"])
line_btn = gr.Button("Read line", variant="primary")
with gr.Accordion("Options", open=False):
line_pad = gr.Slider(0, 24, value=8, step=1,
label="White margin (px)")
with gr.Column(scale=1):
line_out = gr.Textbox(label="Recognised text", lines=4,
buttons=["copy"])
with gr.Tab("Page"):
with gr.Row():
with gr.Column(scale=1):
page_in = gr.Image(type="pil", label="Page scan",
sources=["upload", "clipboard"])
page_btn = gr.Button("Read page", variant="primary")
with gr.Accordion("Options", open=False):
page_col = gr.Radio(["single", "left", "right"],
value="single",
label="Column layout",
info="Two-column pages: read the left "
"half, then the right half.")
page_hyph = gr.Checkbox(value=False,
label="Rejoin hyphenated words")
with gr.Column(scale=1):
page_out = gr.Textbox(label="Page text", lines=16,
buttons=["copy"])
line_btn.click(read_line, inputs=[line_in, line_pad], outputs=line_out,
api_name="read_line")
page_btn.click(read_page, inputs=[page_in, page_col, page_hyph],
outputs=page_out, api_name="read_page")
gr.Markdown("### Examples")
gr.Markdown(
"Real line crops and pages from "
"[LocalDoc/azerbaijani-ocr-benchmark](https://huggingface.co/datasets/LocalDoc/azerbaijani-ocr-benchmark) "
"(CC-BY-4.0), the authors' own evaluation set."
)
gr.Examples(
examples=[["assets/line_latin_0.png"], ["assets/line_cyrillic_0.png"]],
inputs=[line_in], outputs=line_out, fn=read_line,
cache_examples=True, cache_mode="lazy",
label="Line mode",
)
gr.Examples(
examples=[["assets/page_latin.png", "single", False],
["assets/page_cyrillic.png", "single", False]],
inputs=[page_in, page_col, page_hyph], outputs=page_out, fn=read_page,
cache_examples=True, cache_mode="lazy",
label="Page mode",
)
if __name__ == "__main__":
demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)