indic-ocr / app.py
Sidd2899's picture
Deploy Indic OCR Studio
9e7ba62 verified
Raw History Blame Contribute Delete
5.76 kB
"""
app.py - FastAPI server: serves the UI at / and the OCR API at /api/*.
uvicorn app:app --host 0.0.0.0 --port 8000 # real model (GPU)
OCR_MOCK=1 uvicorn app:app --port 8000 # UI development, no GPU
"""
import difflib, os, re, shutil, tempfile, threading, time, uuid
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from ocr_engine import CONFIG, IMAGE_EXT, LANG_NAMES, OCREngine, mask_ids
try: # benchmark.py needs validate.py; without it, no ground-truth scoring
from benchmark import score
except ImportError:
score = None
MAX_UPLOAD_MB = int(os.getenv("OCR_MAX_UPLOAD_MB", "25"))
JOB_TTL_S = int(os.getenv("OCR_JOB_TTL_S", "3600")) # uploads + outputs deleted after this
JOBS_DIR = Path(os.getenv("OCR_JOBS_DIR", tempfile.gettempdir())) / "ocr_jobs"
JOBS_DIR.mkdir(parents=True, exist_ok=True)
STATIC = Path(__file__).parent / "static"
app = FastAPI(title="Indic Handwritten OCR")
ENGINE: OCREngine | None = None
_load_error: str | None = None
def get_engine() -> OCREngine:
"""Loads the model once. Modal / Colab can also inject a pre-loaded engine via set_engine()."""
global ENGINE, _load_error
if ENGINE is None:
try:
ENGINE = OCREngine.load()
except Exception as e: # surface load failures to /api/health
_load_error = repr(e)
raise
return ENGINE
def set_engine(engine: OCREngine):
global ENGINE
ENGINE = engine
@app.on_event("startup")
def _warm():
if os.getenv("OCR_EAGER_LOAD", "1") == "1":
threading.Thread(target=get_engine, daemon=True).start()
threading.Thread(target=_janitor, daemon=True).start()
def _janitor():
while True:
cutoff = time.time() - JOB_TTL_S
for d in JOBS_DIR.iterdir():
if d.is_dir() and d.stat().st_mtime < cutoff:
shutil.rmtree(d, ignore_errors=True)
time.sleep(300)
@app.get("/api/health")
def health():
return {"ready": bool(ENGINE and ENGINE.ready), "mock": CONFIG["MOCK"], "error": _load_error,
"languages": LANG_NAMES, "max_upload_mb": MAX_UPLOAD_MB, "max_pages": CONFIG["MAX_PAGES"],
"scoring": score is not None}
def evaluate(result: dict, gt: str, lang: str | None) -> dict:
"""Same metrics as benchmark.py's run_docs(), plus a word diff for the UI."""
pred = "\n".join(b["text"] for p in result["pages"] for b in p["blocks"])
gt = mask_ids(gt.strip()) # the output is masked, so mask the ground truth the same way
tok = lambda s: re.findall(r"\S+", s)
g, p = tok(gt), tok(pred)
diff = []
for op, i0, i1, j0, j1 in difflib.SequenceMatcher(None, g, p, autojunk=False).get_opcodes():
if op == "equal":
diff.append(["eq", " ".join(g[i0:i1])])
if op in ("delete", "replace"):
diff.append(["del", " ".join(g[i0:i1])]) # in ground truth, missing from OCR
if op in ("insert", "replace"):
diff.append(["ins", " ".join(p[j0:j1])]) # in OCR, not in ground truth
return {**score(gt, pred, lang or "-"), "gt_chars": len(gt), "gt_words": len(g), "diff": diff}
@app.post("/api/ocr")
def ocr(file: UploadFile = File(...), lang: str = Form(""), gt: UploadFile | None = File(None)):
# sync def -> FastAPI runs it in a worker thread; the engine lock serialises GPU access
suffix = Path(file.filename or "").suffix.lower()
if suffix not in IMAGE_EXT | {".pdf"}:
raise HTTPException(400, f"Unsupported file type '{suffix}'. Use PDF or an image.")
job_id = uuid.uuid4().hex[:12]
job_dir = JOBS_DIR / job_id
job_dir.mkdir()
safe_name = Path(file.filename).name.replace(" ", "_")
src = job_dir / safe_name
size = 0
with src.open("wb") as fh:
while chunk := file.file.read(1 << 20):
size += len(chunk)
if size > MAX_UPLOAD_MB << 20:
shutil.rmtree(job_dir, ignore_errors=True)
raise HTTPException(413, f"File is larger than {MAX_UPLOAD_MB} MB")
fh.write(chunk)
gt_text = None
if gt is not None and gt.filename:
if score is None:
shutil.rmtree(job_dir, ignore_errors=True)
raise HTTPException(400, "Ground-truth scoring needs validate.py and benchmark.py on the server")
try:
gt_text = gt.file.read(5 << 20).decode("utf-8-sig")
except UnicodeDecodeError:
shutil.rmtree(job_dir, ignore_errors=True)
raise HTTPException(400, "Ground truth must be a UTF-8 .txt file")
try:
result = get_engine().process(src, lang or None, job_dir / "out")
except ValueError as e:
shutil.rmtree(job_dir, ignore_errors=True)
raise HTTPException(400, str(e))
except Exception as e:
shutil.rmtree(job_dir, ignore_errors=True)
raise HTTPException(500, f"OCR failed: {e}")
src.unlink(missing_ok=True) # don't keep the uploaded document around
result["job_id"] = job_id
if gt_text is not None:
result["eval"] = evaluate(result, gt_text, lang or None)
return JSONResponse(result)
@app.get("/api/jobs/{job_id}/{name}")
def download(job_id: str, name: str):
if not job_id.isalnum() or "/" in name or ".." in name:
raise HTTPException(400, "bad path")
p = JOBS_DIR / job_id / "out" / name
if not p.is_file():
raise HTTPException(404, "expired or not found")
return FileResponse(p, filename=name)
app.mount("/", StaticFiles(directory=STATIC, html=True), name="ui")