PP-OCRv5 β€” LiteRT on-device (fully GPU)

PP-OCRv5 on-device OCR on a Pixel 8a

On-device LiteRT conversion of PP-OCRv5 (PaddleOCR 2025, Apache-2.0) text detection + recognition, running fully on the CompiledModel GPU delegate (LITERT_CL). Detects text regions in an image and reads each line. The recognizer uses a CTC head (no autoregressive decoder), so both stages ride the GPU with no CPU/ONNX fallback β€” unlike VLM-based OCR (Florence-2 / GOT-OCR) whose AR decoder must run on CPU. Device-verified on a Pixel 8a.

Files

File Size Delegate In β†’ Out
ppocr_det_fp16.tflite 10 MB GPU image [1,3,640,640] β†’ prob map [1,1,640,640]
ppocr_rec_fp16.tflite 17 MB GPU line [1,3,48,320] β†’ CTC logits [1,T,18385]
ppocrv5_dict.txt β€” CPU 18383-char dictionary (CTC layout: blank + dict + space)

Pixel 8a: detector 777/777 + recognizer 827/827 on LITERT_CL, ~9 ms each; a 3-line image read 3/3 correct ("Hello OCR 2026" / "PP-OCRv5 on GPU" / "LiteRT CompiledModel").

Pipeline

image β†’[GPU detector]β†’ prob map β†’ [CPU: threshold + connected components + unclip] β†’ boxes
   β†’ crop+resize β†’[GPU recognizer]β†’ CTC logits β†’ [CPU: CTC greedy decode] β†’ text

Minimal usage

Android (Kotlin, CompiledModel GPU)

val det = CompiledModel.create(context.assets, "ppocr_det_fp16.tflite",
    CompiledModel.Options(Accelerator.GPU), null)
val rec = CompiledModel.create(context.assets, "ppocr_rec_fp16.tflite",
    CompiledModel.Options(Accelerator.GPU), null)
val dIn = det.createInputBuffers(); val dOut = det.createOutputBuffers()
dIn[0].writeFloat(image)            // [1,3,640,640] NCHW, /255 then ImageNet mean/std
det.run(dIn, dOut)
val prob = dOut[0].readFloat()      // [1,1,640,640] text probability -> boxes (CPU)
val rIn = rec.createInputBuffers(); val rOut = rec.createOutputBuffers()
rIn[0].writeFloat(lineCrop)         // [1,3,48,320] NCHW, (x/255 - 0.5)/0.5
rec.run(rIn, rOut)
val logits = rOut[0].readFloat()    // [1,T,18385] -> CTC greedy decode (Python below)

Python (desktop verification)

import cv2, numpy as np
from ai_edge_litert.interpreter import Interpreter

MEAN, STD = np.array([0.485, 0.456, 0.406]), np.array([0.229, 0.224, 0.225])
im640 = cv2.resize(cv2.cvtColor(cv2.imread("doc.jpg"), cv2.COLOR_BGR2RGB), (640, 640))
x = ((im640 / 255 - MEAN) / STD).transpose(2, 0, 1)[None].astype(np.float32)

det = Interpreter(model_path="ppocr_det_fp16.tflite"); det.allocate_tensors()
det.set_tensor(det.get_input_details()[0]["index"], x); det.invoke()
prob = det.get_tensor(det.get_output_details()[0]["index"])[0, 0]     # [640,640]

rec = Interpreter(model_path="ppocr_rec_fp16.tflite"); rec.allocate_tensors()
chars = [""] + open("ppocrv5_dict.txt", encoding="utf-8").read().splitlines() + [" "]

n, labels, stats, _ = cv2.connectedComponentsWithStats((prob > 0.3).astype(np.uint8))
for i in range(1, n):                                                 # each text region
    x0, y0, w, h, _ = stats[i]
    if w < 6 or h < 6 or prob[labels == i].mean() < 0.5: continue
    pad = int(np.clip(0.35 * min(w, h), 2, 24))                       # approx DB unclip
    crop = im640[max(y0 - pad, 0):y0 + h + pad, max(x0 - pad, 0):x0 + w + pad]
    rw = min(max(round(48 * crop.shape[1] / crop.shape[0]), 1), 320)  # keep-aspect h=48
    line = np.zeros((48, 320, 3), np.float32)                         # pad to width 320
    line[:, :rw] = cv2.resize(crop, (rw, 48))
    lx = ((line / 255 - 0.5) / 0.5).transpose(2, 0, 1)[None].astype(np.float32)
    rec.set_tensor(rec.get_input_details()[0]["index"], lx); rec.invoke()
    ids = rec.get_tensor(rec.get_output_details()[0]["index"])[0].argmax(-1)   # [T]
    text = "".join(chars[c] for t, c in enumerate(ids)                # CTC: collapse repeats,
                   if c != 0 and (t == 0 or c != ids[t - 1]))         # drop blank (id 0)
    print((x0, y0), text)

Re-authoring (litert-torch, parity corr 1.0)

  • Detector DB-head ConvTranspose2d β†’ ZeroStuffConvT2d (2D nearest-upsample Γ— stride zero-stuff mask
    • flipped conv2d; TRANSPOSE_CONV is Mali-rejected). Numerically exact.
  • Recognizer SVTR attention fused-QKV 5D reshape β†’ split q/k/v into 4D (numerically identical).

Preprocessing: detector = ImageNet mean/std, /255, NCHW, 640Γ—640. recognizer = resize to h=48 keep-aspect, pad to width 320, (img/255βˆ’0.5)/0.5.

Sample app

A complete Android sample app + the conversion scripts are in the official LiteRT samples repository under compiled_model_api/ocr (google-ai-edge/litert-samples). Push these files to the app's filesDir with that sample's install_to_device.sh.

Weights are converted from PaddleOCR via the PaddleOCR2Pytorch port (Apache-2.0). License follows upstream PaddleOCR (Apache-2.0).

Downloads last month
26
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support