Upload ONNX export
Browse files- export_pp_doclayout_v3.py +215 -0
export_pp_doclayout_v3.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Export PaddlePaddle/PP-DocLayoutV3_safetensors to ONNX.
|
| 2 |
+
|
| 3 |
+
The HF module returns a large structured output (intermediate decoder states,
|
| 4 |
+
per-layer masks, reference points...). Only four tensors are needed at serving
|
| 5 |
+
time, so we wrap the model and return exactly those:
|
| 6 |
+
|
| 7 |
+
logits (B, 300, num_classes)
|
| 8 |
+
pred_boxes (B, 300, 4) cxcywh, normalised
|
| 9 |
+
order_logits (B, 300, 300) reading-order pointer matrix
|
| 10 |
+
out_masks (B, 300, 200, 200) mask logits @ stride 4 (optional)
|
| 11 |
+
|
| 12 |
+
`out_masks` is by far the biggest tensor (300*200*200*4B = 48 MB per image in
|
| 13 |
+
fp32). Export with --no-masks if you only need boxes + reading order; polygons
|
| 14 |
+
then degrade to axis-aligned rectangles.
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
python export_pp_doclayout_v3.py --output pp_doclayoutv3.onnx
|
| 18 |
+
python export_pp_doclayout_v3.py --output pp_doclayoutv3_nomask.onnx --no-masks
|
| 19 |
+
python export_pp_doclayout_v3.py --output m.onnx --fp16 # extra fp16 copy
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
from contextlib import contextmanager
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
import torch
|
| 30 |
+
from torch import nn
|
| 31 |
+
from transformers import AutoModelForObjectDetection
|
| 32 |
+
|
| 33 |
+
DEFAULT_MODEL = "PaddlePaddle/PP-DocLayoutV3_safetensors"
|
| 34 |
+
INPUT_SIZE = 800 # preprocessor_config.json: {"height": 800, "width": 800}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@contextmanager
|
| 38 |
+
def fp32_position_embedding():
|
| 39 |
+
"""Force the 2D sin/cos position embedding to float32 during tracing.
|
| 40 |
+
|
| 41 |
+
Upstream computes the frequency grid in float64, which bakes Sin/Cos(double)
|
| 42 |
+
nodes into the graph. ONNX Runtime's CPU EP has no double kernel for those, so
|
| 43 |
+
the session fails to load with:
|
| 44 |
+
NOT_IMPLEMENTED : Could not find an implementation for Cos(7)
|
| 45 |
+
The embedding is cast to float32 anyway; the difference is ~1e-6.
|
| 46 |
+
"""
|
| 47 |
+
from transformers.models.pp_doclayout_v3 import modeling_pp_doclayout_v3 as M
|
| 48 |
+
|
| 49 |
+
original = M.build_2d_sinusoidal_position_embedding
|
| 50 |
+
|
| 51 |
+
def patched(height, width, embed_dim=256, temperature=10000.0,
|
| 52 |
+
cls_token=False, device=None, dtype=torch.float32):
|
| 53 |
+
pos_dim = embed_dim // 4
|
| 54 |
+
omega = torch.arange(pos_dim, dtype=torch.float32, device=device) / pos_dim
|
| 55 |
+
omega = 1.0 / temperature**omega
|
| 56 |
+
grid_h = torch.arange(height, dtype=torch.float32, device=device)
|
| 57 |
+
grid_w = torch.arange(width, dtype=torch.float32, device=device)
|
| 58 |
+
grid_h, grid_w = torch.meshgrid(grid_h, grid_w, indexing="ij")
|
| 59 |
+
emb_h = grid_h.flatten().outer(omega)
|
| 60 |
+
emb_w = grid_w.flatten().outer(omega)
|
| 61 |
+
pos_embed = torch.cat([emb_h.sin(), emb_h.cos(), emb_w.sin(), emb_w.cos()], dim=1)
|
| 62 |
+
if cls_token:
|
| 63 |
+
zeros = torch.zeros(1, embed_dim, dtype=torch.float32, device=device)
|
| 64 |
+
pos_embed = torch.cat([zeros, pos_embed], dim=0)
|
| 65 |
+
return pos_embed.to(dtype)
|
| 66 |
+
|
| 67 |
+
M.build_2d_sinusoidal_position_embedding = patched
|
| 68 |
+
try:
|
| 69 |
+
yield
|
| 70 |
+
finally:
|
| 71 |
+
M.build_2d_sinusoidal_position_embedding = original
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class PPDocLayoutV3ExportWrapper(nn.Module):
|
| 75 |
+
"""Flattens the HF output struct down to the tensors post-processing needs."""
|
| 76 |
+
|
| 77 |
+
def __init__(self, model: nn.Module, with_masks: bool = True) -> None:
|
| 78 |
+
super().__init__()
|
| 79 |
+
self.model = model
|
| 80 |
+
self.with_masks = with_masks
|
| 81 |
+
|
| 82 |
+
def forward(self, pixel_values: torch.Tensor):
|
| 83 |
+
out = self.model(pixel_values=pixel_values)
|
| 84 |
+
|
| 85 |
+
order_logits = out.order_logits
|
| 86 |
+
# Doc types it as a tuple; take the final decoder layer if so.
|
| 87 |
+
if isinstance(order_logits, (tuple, list)):
|
| 88 |
+
order_logits = order_logits[-1]
|
| 89 |
+
if order_logits.dim() == 4: # (B, layers, Q, Q)
|
| 90 |
+
order_logits = order_logits[:, -1]
|
| 91 |
+
|
| 92 |
+
if not self.with_masks:
|
| 93 |
+
return out.logits, out.pred_boxes, order_logits
|
| 94 |
+
|
| 95 |
+
masks = out.out_masks
|
| 96 |
+
if masks.dim() == 5: # (B, layers, Q, H, W) -> last layer only
|
| 97 |
+
masks = masks[:, -1]
|
| 98 |
+
return out.logits, out.pred_boxes, order_logits, masks
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def export(
|
| 102 |
+
model_path: str,
|
| 103 |
+
output: Path,
|
| 104 |
+
with_masks: bool = True,
|
| 105 |
+
opset: int = 17,
|
| 106 |
+
dynamo: bool = False,
|
| 107 |
+
dynamic_batch: bool = True,
|
| 108 |
+
) -> tuple[nn.Module, list[str]]:
|
| 109 |
+
model = AutoModelForObjectDetection.from_pretrained(model_path, dtype=torch.float32).eval()
|
| 110 |
+
# Pure-PyTorch deformable attention (grid_sample) instead of the custom CUDA
|
| 111 |
+
# kernel — the custom op has no ONNX symbolic. The checkpoint config already
|
| 112 |
+
# sets this, but be explicit in case someone overrides it.
|
| 113 |
+
model.config.disable_custom_kernels = True
|
| 114 |
+
|
| 115 |
+
wrapper = PPDocLayoutV3ExportWrapper(model, with_masks=with_masks).eval()
|
| 116 |
+
|
| 117 |
+
dummy = torch.randn(1, 3, INPUT_SIZE, INPUT_SIZE)
|
| 118 |
+
output_names = ["logits", "pred_boxes", "order_logits"]
|
| 119 |
+
if with_masks:
|
| 120 |
+
output_names.append("out_masks")
|
| 121 |
+
|
| 122 |
+
# Height/width stay static: the image processor always resizes to 800x800,
|
| 123 |
+
# and static spatial dims let ORT constant-fold the anchor generation.
|
| 124 |
+
dynamic_axes = None
|
| 125 |
+
if dynamic_batch:
|
| 126 |
+
dynamic_axes = {name: {0: "batch"} for name in ["pixel_values"] + output_names}
|
| 127 |
+
|
| 128 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 129 |
+
with torch.inference_mode(), fp32_position_embedding():
|
| 130 |
+
torch.onnx.export(
|
| 131 |
+
wrapper,
|
| 132 |
+
(dummy,),
|
| 133 |
+
str(output),
|
| 134 |
+
input_names=["pixel_values"],
|
| 135 |
+
output_names=output_names,
|
| 136 |
+
dynamic_axes=dynamic_axes,
|
| 137 |
+
opset_version=opset,
|
| 138 |
+
do_constant_folding=True,
|
| 139 |
+
dynamo=dynamo,
|
| 140 |
+
)
|
| 141 |
+
print(f"Exported -> {output} ({output.stat().st_size / 1e6:.1f} MB)")
|
| 142 |
+
return wrapper, output_names
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def check_parity(
|
| 146 |
+
wrapper: nn.Module, onnx_path: Path, output_names: list[str], batch: int = 2, atol: float = 1e-3
|
| 147 |
+
) -> None:
|
| 148 |
+
"""Compare ONNX Runtime against PyTorch on random input.
|
| 149 |
+
|
| 150 |
+
Note: this is only meaningful with the real pretrained weights. With randomly
|
| 151 |
+
initialised weights the encoder emits thousands of identical proposal scores,
|
| 152 |
+
so TopK query selection is arbitrary and torch/ORT legitimately disagree.
|
| 153 |
+
"""
|
| 154 |
+
import onnxruntime as ort
|
| 155 |
+
|
| 156 |
+
x = torch.randn(batch, 3, INPUT_SIZE, INPUT_SIZE)
|
| 157 |
+
with torch.inference_mode():
|
| 158 |
+
torch_out = [t.numpy() for t in wrapper(x)]
|
| 159 |
+
|
| 160 |
+
sess = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
|
| 161 |
+
onnx_out = sess.run(None, {"pixel_values": x.numpy()})
|
| 162 |
+
|
| 163 |
+
ok = True
|
| 164 |
+
for name, t, o in zip(output_names, torch_out, onnx_out):
|
| 165 |
+
diff = np.abs(t - o).max()
|
| 166 |
+
status = "OK " if diff < atol else "FAIL"
|
| 167 |
+
ok &= diff < atol
|
| 168 |
+
print(f" {status} {name:14s} shape={tuple(o.shape)} max|diff|={diff:.3e}")
|
| 169 |
+
print("Parity check passed\n" if ok else "Parity check FAILED — do not ship this graph\n")
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def to_fp16(onnx_path: Path) -> Path:
|
| 173 |
+
"""Half-precision copy. Keep normalisation/mask ops in fp32 to avoid overflow."""
|
| 174 |
+
import onnx
|
| 175 |
+
from onnxconverter_common import float16
|
| 176 |
+
|
| 177 |
+
model = onnx.load(str(onnx_path))
|
| 178 |
+
fp16_model = float16.convert_float_to_float16(
|
| 179 |
+
model, keep_io_types=True, op_block_list=["GridSample", "ReduceMean", "Pow", "Sqrt", "Div"]
|
| 180 |
+
)
|
| 181 |
+
out = onnx_path.with_name(onnx_path.stem + "_fp16.onnx")
|
| 182 |
+
onnx.save(fp16_model, str(out))
|
| 183 |
+
print(f"fp16 -> {out} ({out.stat().st_size / 1e6:.1f} MB)")
|
| 184 |
+
return out
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def main() -> int:
|
| 188 |
+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 189 |
+
p.add_argument("--model", default=DEFAULT_MODEL)
|
| 190 |
+
p.add_argument("--output", type=Path, default=Path("pp_doclayoutv3.onnx"))
|
| 191 |
+
p.add_argument("--opset", type=int, default=17, help=">=16 required for GridSample")
|
| 192 |
+
p.add_argument("--no-masks", dest="with_masks", action="store_false")
|
| 193 |
+
p.add_argument("--static-batch", dest="dynamic_batch", action="store_false")
|
| 194 |
+
p.add_argument("--dynamo", action="store_true", help="use the TorchDynamo exporter")
|
| 195 |
+
p.add_argument("--fp16", action="store_true", help="also emit an fp16 copy")
|
| 196 |
+
p.add_argument("--skip-check", action="store_true")
|
| 197 |
+
args = p.parse_args()
|
| 198 |
+
|
| 199 |
+
wrapper, names = export(
|
| 200 |
+
args.model,
|
| 201 |
+
args.output,
|
| 202 |
+
with_masks=args.with_masks,
|
| 203 |
+
opset=args.opset,
|
| 204 |
+
dynamo=args.dynamo,
|
| 205 |
+
dynamic_batch=args.dynamic_batch,
|
| 206 |
+
)
|
| 207 |
+
if not args.skip_check:
|
| 208 |
+
check_parity(wrapper, args.output, names, batch=2 if args.dynamic_batch else 1)
|
| 209 |
+
if args.fp16:
|
| 210 |
+
to_fp16(args.output)
|
| 211 |
+
return 0
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
if __name__ == "__main__":
|
| 215 |
+
raise SystemExit(main())
|