[0.1.31] macOS phase 5: PP-OCRv5 server OCR on the GPU through native Core ML

This commit is contained in:
Musa Mahmood 2026-10-16 20:20:26 -04:00
parent 30cd00b6f2
commit a1ab9f3a96
5 changed files with 48 additions and 10 deletions

View File

@ -30,8 +30,9 @@ see [Screen capture](#screen-capture).
**On macOS**, labelling and OCR run on the GPU through Core ML, from Core ML copies of the models **On macOS**, labelling and OCR run on the GPU through Core ML, from Core ML copies of the models
made once with `tools/export_siglip_coreml.py` (SigLIP 2's image model, about ten times as fast as made once with `tools/export_siglip_coreml.py` (SigLIP 2's image model, about ten times as fast as
the CPU) and `tools/export_ppocr_coreml.py` (PP-OCRv5 mobile's detector and recognizer, about twice the CPU) and `tools/export_ppocr_coreml.py` (PP-OCRv5's detector and recognizer, about twice as
as fast; their first lines say how to set up the Python they need). Without them, each runs on the fast; run it with `--model mobile` and `--model server`; their first lines say how to set up the
Python they need). Without them, each runs on the
CPU. The first scan after making them is slower: Core ML prepares each image size the first time it CPU. The first scan after making them is slower: Core ML prepares each image size the first time it
meets it, and macOS keeps that for later runs. meets it, and macOS keeps that for later runs.

View File

@ -49,7 +49,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check"; GIT_BRANCH :: "check";
GIT_HASH :: "-"; GIT_HASH :: "-";
VERSION :: "0.1.30"; VERSION :: "0.1.31";
BUILD_DATE :: "-"; BUILD_DATE :: "-";
DEBUG :: true; DEBUG :: true;
COMPILE_TESTS :: true; COMPILE_TESTS :: true;

View File

@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
APP_BUNDLE_IDENTIFIER :: "com.example.mcap"; APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
VERSION :: "0.1.30"; VERSION :: "0.1.31";
// Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated // Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated
// builds never write over the ones you run by hand. // builds never write over the ones you run by hand.
build_directory := "bin"; build_directory := "bin";

View File

@ -54,3 +54,5 @@ unclip_ratio: 1.5
rec_height: 48 rec_height: 48
rec_batch_gpu: 8 rec_batch_gpu: 8
rec_batch_cpu: 1 rec_batch_cpu: 1
detector_coreml: "ch_PP-OCRv5_det_server.mlpackage"
recognizer_coreml: "ch_PP-OCRv5_rec_server.mlpackage"

View File

@ -5,7 +5,8 @@
# python3 -m venv ~/mcap-coreml # python3 -m venv ~/mcap-coreml
# ~/mcap-coreml/bin/pip install torch==2.7.0 coremltools==9.0 onnx onnxruntime pillow # ~/mcap-coreml/bin/pip install torch==2.7.0 coremltools==9.0 onnx onnxruntime pillow
# ~/mcap-coreml/bin/pip install --no-deps onnx2torch torchvision==0.22.0 # ~/mcap-coreml/bin/pip install --no-deps onnx2torch torchvision==0.22.0
# ~/mcap-coreml/bin/python tools/export_ppocr_coreml.py [--model mobile|server] # ~/mcap-coreml/bin/python tools/export_ppocr_coreml.py --model mobile
# ~/mcap-coreml/bin/python tools/export_ppocr_coreml.py --model server
# #
# There is no PyTorch source for PP-OCR (it is PaddlePaddle's), and coremltools no longer reads ONNX, so # There is no PyTorch source for PP-OCR (it is PaddlePaddle's), and coremltools no longer reads ONNX, so
# each model goes ONNX -> PyTorch (onnx2torch) -> Core ML, as an ML Program in float32: float16 moves # each model goes ONNX -> PyTorch (onnx2torch) -> Core ML, as an ML Program in float32: float16 moves
@ -17,12 +18,17 @@
# say the same steps, and must be kept in step with these: # say the same steps, and must be kept in step with these:
# detector: 1 x 3 x H x W, H a multiple of 128 up to 1536 (det_tile_height), W of 256 up to 2560 (det_max_side) # detector: 1 x 3 x H x W, H a multiple of 128 up to 1536 (det_tile_height), W of 256 up to 2560 (det_max_side)
# recognizer: 16 x 3 x 48 x W, W a multiple of 64 up to 4096 (REC_MAX_WIDTH); fewer lines are padded to 16 # recognizer: 16 x 3 x 48 x W, W a multiple of 64 up to 4096 (REC_MAX_WIDTH); fewer lines are padded to 16
# The recognizer's Reshapes compute their target shape from tensor sizes, which tracing freezes at the # The server models pad some 2 x 2 convolutions and max pools with auto_pad SAME_UPPER, which onnx2torch
# traced width; they are rewritten first as constant shapes of 0 (copy the dimension) and -1 (infer it), # does not take; at stride 1 it is a fixed pixel after, written out as pads first.
# worked out from the shapes onnxruntime gives at several sizes, and checked to give the same output. # Reshapes that compute their target shape from tensor sizes (the recognizers', the server detector's)
# are frozen by tracing at the traced size; they are rewritten first as constant shapes of 0 (copy the
# dimension) and -1 (infer it), worked out from the shapes onnxruntime gives at several sizes, and
# checked to give the same output.
# #
# Measured on an M5 Pro against onnxruntime on the CPU, each at the same (padded) size: the detector # Measured on an M5 Pro against onnxruntime on the CPU, each at the same (padded) size: the detector
# about 5 times as fast, the recognizer about 4 times for 16 lines, with the same text pixels and text. # about 5 times as fast, the recognizer about 4 times for 16 lines, with the same text pixels and text.
# Whole images through mcap (ocr-bench, 8 text pages): mobile 323 ms an image against 536, server 940
# against 2030.
import argparse, itertools, os, sys, time import argparse, itertools, os, sys, time
import numpy as np import numpy as np
@ -96,6 +102,34 @@ def constant_reshapes(model, input_shapes):
return model return model
def explicit_padding(model):
"""The model with auto_pad SAME_UPPER and SAME_LOWER, which onnx2torch does not take, written out as
pads. Only stride 1 is rewritten: there the padding does not depend on the input's size (the server
models' 2 x 2 convolutions and max pools: one pixel after, none before)."""
for n in model.graph.node:
attrs = {a.name: a for a in n.attribute}
mode = attrs["auto_pad"].s.decode() if "auto_pad" in attrs else "NOTSET"
if mode not in ("SAME_UPPER", "SAME_LOWER"):
continue
kernel = list(attrs["kernel_shape"].ints) if "kernel_shape" in attrs else None
if kernel is None:
sys.exit(f"{n.name}: auto_pad {mode} with no kernel_shape")
strides = list(attrs["strides"].ints) if "strides" in attrs else [1] * len(kernel)
dilations = list(attrs["dilations"].ints) if "dilations" in attrs else [1] * len(kernel)
if any(s != 1 for s in strides):
sys.exit(f"{n.name}: auto_pad {mode} with strides {strides}; only stride 1 is rewritten")
totals = [(k - 1) * d for k, d in zip(kernel, dilations)]
small = [t // 2 for t in totals]
begins = small if mode == "SAME_UPPER" else [t - b for t, b in zip(totals, small)]
ends = [t - b for t, b in zip(totals, begins)]
n.attribute.remove(attrs["auto_pad"])
if "pads" in attrs:
n.attribute.remove(attrs["pads"])
n.attribute.append(helper.make_attribute("pads", begins + ends))
print(f" {n.name} ({n.op_type}): auto_pad {mode} -> pads {begins + ends}")
return model
def convert(onnx_model, example, shape, path): def convert(onnx_model, example, shape, path):
module = onnx2torch.convert(onnx_model).eval() module = onnx2torch.convert(onnx_model).eval()
with torch.no_grad(): with torch.no_grad():
@ -131,7 +165,8 @@ def main():
out_path = os.path.join(MODELS, stem + ".mlpackage") out_path = os.path.join(MODELS, stem + ".mlpackage")
shapes = [(1, 3, h, w) for h in DET_HEIGHTS for w in DET_WIDTHS] shapes = [(1, 3, h, w) for h in DET_HEIGHTS for w in DET_WIDTHS]
start = time.perf_counter() start = time.perf_counter()
det = convert(onnx.load(onnx_path), np.zeros((1, 3, 768, 1280), np.float32), model = constant_reshapes(explicit_padding(onnx.load(onnx_path)), [(1, 3, h, w) for h in (256, 512) for w in (512, 768)])
det = convert(model, np.zeros((1, 3, 768, 1280), np.float32),
ct.EnumeratedShapes(shapes=shapes, default=(1, 3, 768, 1280)), out_path) ct.EnumeratedShapes(shapes=shapes, default=(1, 3, 768, 1280)), out_path)
print(f"Wrote {out_path} ({len(shapes)} sizes) in {time.perf_counter() - start:.1f} s.") print(f"Wrote {out_path} ({len(shapes)} sizes) in {time.perf_counter() - start:.1f} s.")
reference = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"]) reference = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
@ -151,7 +186,7 @@ def main():
onnx_path = os.path.join(MODELS, stem + ".onnx") onnx_path = os.path.join(MODELS, stem + ".onnx")
out_path = os.path.join(MODELS, stem + ".mlpackage") out_path = os.path.join(MODELS, stem + ".mlpackage")
print("Recognizer: making its reshapes constant") print("Recognizer: making its reshapes constant")
model = constant_reshapes(onnx.load(onnx_path), [(n, 3, REC_HEIGHT, w) for n in (1, 2) for w in (320, 640)]) model = constant_reshapes(explicit_padding(onnx.load(onnx_path)), [(n, 3, REC_HEIGHT, w) for n in (1, 2) for w in (320, 640)])
shapes = [(REC_BATCH, 3, REC_HEIGHT, w) for w in REC_WIDTHS] shapes = [(REC_BATCH, 3, REC_HEIGHT, w) for w in REC_WIDTHS]
start = time.perf_counter() start = time.perf_counter()
rec = convert(model, np.zeros((REC_BATCH, 3, REC_HEIGHT, 640), np.float32), rec = convert(model, np.zeros((REC_BATCH, 3, REC_HEIGHT, 640), np.float32),