ocr-iiif-search / app.py
davanstrien's picture
davanstrien HF Staff
Mirador + IIIF Image API + Content Search over Surya OCR lines
ecd2a6a verified
Raw
History Blame Contribute Delete
5.32 kB
import io, json
from fastapi import FastAPI, Request, Query
from fastapi.responses import JSONResponse, HTMLResponse, Response
from PIL import Image
app = FastAPI()
IMG = Image.open("page.jpg").convert("RGB")
W, H = IMG.size
LINES = [l for l in json.load(open("lines.json")) if l.get("text")]
CORS = {"Access-Control-Allow-Origin": "*"}
def base(req): return str(req.base_url).rstrip("/")
def canvas(b): return f"{b}/canvas/p1"
# ---- minimal dynamic IIIF Image API 2.1 (level1) ----
def parse_region(r):
if r in ("full", "square"): return (0, 0, W, H)
if r.startswith("pct:"):
x, y, w, h = [float(v) for v in r[4:].split(",")]
return (int(x/100*W), int(y/100*H), int(w/100*W), int(h/100*H))
x, y, w, h = [int(float(v)) for v in r.split(",")]
return (x, y, w, h)
def parse_size(s, rw, rh):
if s in ("full", "max", "^max"): return (rw, rh)
if s.startswith("pct:"):
p = float(s[4:]) / 100; return (max(1, int(rw*p)), max(1, int(rh*p)))
fit = s.startswith("!"); s = s[1:] if fit else s
w, h = (s.split(",") + [""])[:2]
if w and not h: w = int(w); return (w, max(1, round(rh*w/rw)))
if h and not w: h = int(h); return (max(1, round(rw*h/rh)), h)
w, h = int(w), int(h)
if fit:
sc = min(w/rw, h/rh); return (max(1, int(rw*sc)), max(1, int(rh*sc)))
return (w, h)
@app.get("/iiif/page/info.json")
def info(req: Request):
b = base(req)
return JSONResponse({"@context": "http://iiif.io/api/image/2/context.json",
"@id": f"{b}/iiif/page", "@type": "iiif:Image",
"protocol": "http://iiif.io/api/image", "width": W, "height": H,
"profile": ["http://iiif.io/api/image/2/level1.json"],
"tiles": [{"width": 1024, "scaleFactors": [1, 2, 4, 8, 16, 32]}]}, headers=CORS)
@app.get("/iiif/page/{region}/{size}/{rotation}/{quality}.{fmt}")
def tile(region: str, size: str, rotation: str, quality: str, fmt: str):
x, y, w, h = parse_region(region)
crop = IMG.crop((x, y, x + w, y + h))
tw, th = parse_size(size, w, h)
crop = crop.resize((max(1, tw), max(1, th)))
if quality == "gray": crop = crop.convert("L").convert("RGB")
buf = io.BytesIO(); crop.save(buf, "JPEG", quality=85)
return Response(buf.getvalue(), media_type="image/jpeg", headers=CORS)
# ---- Presentation 3.0 manifest (image service + OCR annotations + search service) ----
@app.get("/manifest.json")
def manifest(req: Request):
b = base(req); c = canvas(b)
anns = []
for i, ln in enumerate(LINES):
x0, y0, x1, y1 = [int(round(v)) for v in ln["bbox"]]
anns.append({"id": f"{b}/anno/l{i}", "type": "Annotation", "motivation": "supplementing",
"body": {"type": "TextualBody", "format": "text/plain", "language": "en", "value": ln["text"]},
"target": f"{c}#xywh={x0},{y0},{max(1,x1-x0)},{max(1,y1-y0)}"})
m = {"@context": "http://iiif.io/api/presentation/3/context.json",
"id": f"{b}/manifest.json", "type": "Manifest",
"label": {"en": ["The Commoner, 1901-01-23, p.1 — Surya OCR 2 (line-level)"]},
"service": [{"id": f"{b}/search", "type": "SearchService1",
"profile": "http://iiif.io/api/search/1/search"}],
"items": [{"id": c, "type": "Canvas", "width": W, "height": H,
"items": [{"id": f"{c}/page", "type": "AnnotationPage", "items": [
{"id": f"{c}/painting", "type": "Annotation", "motivation": "painting",
"body": {"id": f"{b}/iiif/page/full/max/0/default.jpg", "type": "Image",
"format": "image/jpeg", "width": W, "height": H,
"service": [{"id": f"{b}/iiif/page", "type": "ImageService2", "profile": "level1"}]},
"target": c}]}],
"annotations": [{"id": f"{c}/ocr", "type": "AnnotationPage", "items": anns}]}]}
return JSONResponse(m, headers=CORS)
# ---- IIIF Content Search 1.0 (Mirador's search box queries this) ----
@app.get("/search")
def search(req: Request, q: str = Query("")):
b = base(req); c = canvas(b); ql = q.lower().strip()
res = []
for i, ln in enumerate(LINES):
if ql and ql in ln["text"].lower():
x0, y0, x1, y1 = [int(round(v)) for v in ln["bbox"]]
res.append({"@id": f"{b}/anno/s{i}", "@type": "oa:Annotation", "motivation": "sc:painting",
"resource": {"@type": "cnt:ContentAsText", "chars": ln["text"]},
"on": f"{c}#xywh={x0},{y0},{max(1,x1-x0)},{max(1,y1-y0)}"})
return JSONResponse({"@context": "http://iiif.io/api/presentation/2/context.json",
"@id": f"{b}/search?q={q}", "@type": "sc:AnnotationList",
"within": {"@type": "sc:Layer", "total": len(res)}, "resources": res}, headers=CORS)
@app.get("/", response_class=HTMLResponse)
def index():
return """<!DOCTYPE html><html><head><meta charset="utf-8">
<title>Surya OCR 2 — IIIF search (Mirador)</title>
<style>html,body,#m{height:100%;width:100%;margin:0}</style></head>
<body><div id="m"></div>
<script src="https://unpkg.com/mirador@3.3.0/dist/mirador.min.js"></script>
<script>
Mirador.viewer({ id:'m',
window:{ allowFullscreen:true, sideBarOpenByDefault:true, defaultSideBarPanel:'search' },
windows:[{ manifestId: location.origin + '/manifest.json' }] });
</script></body></html>"""