unixio's picture
feat: kraken blla baseline segmentation as primary /segment engine (docTR fallback)
7a00c7e verified
Raw
History Blame Contribute Delete
9.85 kB
"""
BilinguaScript HTR API
FastAPI application factory with /health and /transcribe routes.
"""
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import JSONResponse
from predict import run_inference, normalize_lang, EN_MODEL_ID, FR_MODEL_ID
import preprocess, logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="BilinguaScript HTR API", version="1.1.0")
@app.get("/")
def root():
"""Landing response for the Space page - the real work happens on /api/v1/transcribe."""
return {
"service": "BiLinguaScript HTR API",
"description": "Bilingual English/French handwriting recognition for Cameroonian documents (fine-tuned TrOCR).",
"models": {"en": EN_MODEL_ID, "fr": FR_MODEL_ID},
"endpoints": {
"health": "GET /health",
"transcribe": "POST /api/v1/transcribe (multipart: file, language=en|fr|auto, segment=true|false)",
},
"docs": "/docs",
}
@app.get("/health")
def health():
return {
"status": "ok",
"models": {"en": EN_MODEL_ID, "fr": FR_MODEL_ID},
}
@app.post("/api/v1/transcribe")
async def transcribe(
file: UploadFile = File(...),
language: str = Form(default="auto"),
segment: bool = Form(default=True),
):
"""Transcribe an uploaded image.
- language: 'en'/'english', 'fr'/'french', or 'auto' (defaults to English).
- segment: true -> split the image into lines first (full-page input);
false -> treat the image as a single pre-cropped line (this is
what the Django backend sends, since it segments with its own
pipeline before calling us — re-segmenting a line crop here
would waste time and risk splitting tall ascenders/descenders).
"""
data = await file.read()
if not data:
raise HTTPException(status_code=400, detail="Empty file.")
img = preprocess.decode_image(data)
if img is None:
raise HTTPException(status_code=422, detail="Could not decode image.")
lang = normalize_lang(language)
if segment:
lines = preprocess.segment_lines(img)
else:
lines = [(0, img)]
results = []
for idx, line_img in lines:
encoded = preprocess.encode_png(line_img)
result = run_inference(encoded, lang)
results.append({"line_index": idx, **result})
return JSONResponse({"language": lang, "lines": results})
# ── Kraken blla baseline segmentation (primary engine) ───────────────────────
# kraken's blla neural segmenter is the open-source counterpart of
# Transkribus' ARU-Net baseline detection: it emits reading-order baselines
# with bounding polygons that already include ascenders and descenders, so no
# vertical-padding heuristics are needed. kraken is installed --no-deps on the
# Space (its torch~=2.1 metadata pin conflicts with our torch==2.2.2, which it
# runs on fine), so treat any import/model/segment failure as non-fatal and
# fall back to the docTR word-box grouping below.
_kraken_model = None
_kraken_disabled = False
def _get_kraken_model():
"""Load and cache kraken's default blla model (shipped as package data)."""
global _kraken_model
if _kraken_model is None:
import os
import kraken
from kraken.lib import vgsl
logger.info("Loading kraken blla segmentation model...")
path = os.path.join(os.path.dirname(kraken.__file__), 'blla.mlmodel')
_kraken_model = vgsl.TorchVGSLModel.load_model(path)
return _kraken_model
def _kraken_lines_to_bboxes(seg, page_w, page_h):
"""Convert a kraken Segmentation (or legacy dict) to reading-order bboxes."""
lines = getattr(seg, 'lines', None)
if lines is None and isinstance(seg, dict):
lines = seg.get('lines', [])
out = []
for ln in lines or []:
poly = getattr(ln, 'boundary', None)
if poly is None and isinstance(ln, dict):
poly = ln.get('boundary')
if not poly:
continue
xs = [p[0] for p in poly]
ys = [p[1] for p in poly]
x0 = max(0, int(min(xs)))
y0 = max(0, int(min(ys)))
x1 = min(page_w, int(max(xs)) + 1)
y1 = min(page_h, int(max(ys)) + 1)
if (x1 - x0) >= 4 and (y1 - y0) >= 4:
out.append({'x': x0, 'y': y0, 'w': x1 - x0, 'h': y1 - y0})
return out
def _kraken_segment(bgr_img, page_w, page_h):
"""Run kraken blla on a BGR ndarray; returns bbox lines or None on failure."""
global _kraken_disabled
if _kraken_disabled:
return None
try:
import cv2
from PIL import Image
from kraken import blla
pil = Image.fromarray(cv2.cvtColor(bgr_img, cv2.COLOR_BGR2RGB))
seg = blla.segment(pil, model=_get_kraken_model())
lines = _kraken_lines_to_bboxes(seg, page_w, page_h)
if lines:
return lines
logger.warning("kraken found no lines; falling back to docTR for this page")
return None
except ImportError as e:
_kraken_disabled = True
logger.warning("kraken unavailable (%s); docTR fallback active for this process", e)
return None
except Exception as e:
logger.warning("kraken segmentation failed (%s); docTR fallback for this page", e)
return None
# ── Layout-aware line segmentation (docTR text detection, fallback) ──────────
# The naive projection segmenter fails on phone photos, ruled paper, tables
# and diagrams. docTR's pretrained DB-ResNet detector finds word boxes
# robustly; the grouping below merges them into reading-order lines using the
# same tolerance rule validated in the dissertation corpus pipeline:
# tau = min(alpha * median(word_height), beta * page_height)
_det_model = None
def _get_detector():
global _det_model
if _det_model is None:
from doctr.models import detection_predictor
logger.info("Loading docTR detection model (db_resnet50)...")
_det_model = detection_predictor('db_resnet50', pretrained=True)
return _det_model
def _group_words_into_lines(boxes, page_w, page_h,
alpha=0.6, beta=0.012, min_gap_px=18):
"""boxes: list of (x0, y0, x1, y1) absolute pixels -> list of line bboxes."""
import numpy as np
if not boxes:
return []
heights = np.array([b[3] - b[1] for b in boxes])
# Exclude outlier-tall boxes (headers, diagram labels) from the estimate
med_h = float(np.median(heights[heights <= np.percentile(heights, 90)])) if len(heights) > 2 else float(np.median(heights))
tau = min(alpha * med_h, max(min_gap_px, beta * page_h))
boxes = sorted(boxes, key=lambda b: (b[1] + b[3]) / 2)
lines = []
for b in boxes:
cy = (b[1] + b[3]) / 2
placed = False
for line in lines:
if abs(cy - line['cy']) <= tau:
line['boxes'].append(b)
cys = [(x[1] + x[3]) / 2 for x in line['boxes']]
line['cy'] = sum(cys) / len(cys)
placed = True
break
if not placed:
lines.append({'cy': cy, 'boxes': [b]})
out = []
# Expand each line box the way baseline-based systems (Transkribus'
# ARU-Net, kraken's blla) build their bounding polygons: detectors fire
# on the x-height band, so without generous vertical margins the crops
# decapitate ascenders and cut descenders - exactly the strokes cursive
# readers need. ~30% of the line height above and ~35% below recovers
# them; horizontal padding stays small.
pad_x = max(2, int(med_h * 0.15))
for line in sorted(lines, key=lambda l: l['cy']):
xs0 = min(b[0] for b in line['boxes']); ys0 = min(b[1] for b in line['boxes'])
xs1 = max(b[2] for b in line['boxes']); ys1 = max(b[3] for b in line['boxes'])
line_h = ys1 - ys0
pad_top = int(line_h * 0.30)
pad_bot = int(line_h * 0.35)
y = max(0, int(ys0 - pad_top))
out.append({
'x': max(0, int(xs0 - pad_x)), 'y': y,
'w': min(page_w, int(xs1 + pad_x)) - max(0, int(xs0 - pad_x)),
'h': min(page_h, int(ys1 + pad_bot)) - y,
})
return out
@app.post("/api/v1/segment")
async def segment_endpoint(file: UploadFile = File(...)):
"""Detect handwriting lines on a full page; returns reading-order bboxes.
Primary engine: kraken blla baseline segmentation. Fallback: docTR word
boxes grouped into lines. Response:
{"width": W, "height": H, "engine": "kraken"|"doctr", "lines": [{"x","y","w","h"}, ...]}
"""
data = await file.read()
if not data:
raise HTTPException(status_code=400, detail="Empty file.")
img = preprocess.decode_image(data)
if img is None:
raise HTTPException(status_code=422, detail="Could not decode image.")
h, w = img.shape[:2]
kraken_lines = _kraken_segment(img, w, h)
if kraken_lines is not None:
return JSONResponse({"width": w, "height": h, "engine": "kraken",
"lines": kraken_lines})
import cv2
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
det = _get_detector()
result = det([rgb])
# doctr returns normalized (x0, y0, x1, y1[, score]) per word
raw = result[0].get('words', result[0]) if isinstance(result[0], dict) else result[0]
abs_boxes = []
for b in raw:
x0, y0, x1, y1 = float(b[0]) * w, float(b[1]) * h, float(b[2]) * w, float(b[3]) * h
if (x1 - x0) >= 4 and (y1 - y0) >= 4:
abs_boxes.append((x0, y0, x1, y1))
lines = _group_words_into_lines(abs_boxes, w, h)
return JSONResponse({"width": w, "height": h, "engine": "doctr", "lines": lines})