PP-DocLayoutV3-LiteRT (fp32, 四图切分 + GPU 可用)
PaddleOCR PP-DocLayoutV3(论文名 RT-DocLayout,ECCV 2026)的 LiteRT .tflite 转换产物。
用于端侧(Android / MTK / 高通)文档版面分析:检测框 + 实例分割掩码 + 阅读顺序。
⚠️ 精度声明:全部产物都是 fp32
图 大小 张量 dtype 统计 float16 GraphA (encoder) 107,008,596 B float32 618 · int32 230 GraphM (mask branch) 584,268 B float32 81 · int32 14 · bool 40 GraphB2 (decoder) 28,332,012 B float32 1997 · int32 640 GraphC (order head) 494,676 B float32 36 · int32 9 · bool 30 合计 136,419,552 B float32 2732 · int32 110 · bool 7 = 28490 但请注意:官方规则是 "即使是 fp32 图,delegate 也按 fp16 reduce"。 本模型在 CPU(XNNPACK)上是真 fp32;在 GPU delegate 上实际计算精度会被降到 fp16。 这不是理论风险 —— 它直接决定了下图哪一段能放 GPU(见 §5 后端约束)。
1. 这是什么
| 项 | 值 |
|---|---|
| 上游模型 | PaddlePaddle/PP-DocLayoutV3(RT-DocLayout,DETR / RT-DETR 系) |
| 参数 | 33.18 M(上游)+ 少量冻结常量(sine 位置编码 [1,625,256]、anchors [1,13125,4] 等) |
| 输入 | [1, 3, 800, 800] float32,RGB,0–1 归一化(/255,mean=0,std=1) |
| 输出 | 4 个裸头,分在 4 张图里(见 §4) |
| 类别 | 25 类版面元素(见 §7.1) |
| 格式 | .tflite(FlatBuffers / TFLite Model schema,文件标识 TFL3) |
| 运行时 | LiteRT CompiledModel API(或 TFLite Interpreter) |
| 主机侧需要做的事 | top-k、坐标还原、阈值、掩码上采样、阅读顺序投票 |
为什么不是 1 个文件而是 4 个:整图 179 MB 直接丢给 GPU delegate 编译失败
(grid_sample 展开出 440 个 rank 5/6 张量、two-stage top-k 的 GATHER_ND/TOPK_V2
索引是运行期数据相关的)。更重要的是 —— 逐层二分后发现有三个独立的数值敏感点,
所以切点是由数值敏感度而不是算子支持度决定的。详见 §6。
2. 文件清单
| 文件 | 说明 |
|---|---|
pplayoutv3_graphA_enc_fp32.tflite |
encoder(backbone + hybrid encoder + enc heads)。107.01 MB |
pplayoutv3_graphM_maskbranch_fp32.tflite |
掩码分支(decoder_norm + mask query head + bmm + mask→box)。0.58 MB |
pplayoutv3_graphB2_dec_fp32.tflite |
decoder(6 层 deformable attention + class/box/mask 头)。28.33 MB |
pplayoutv3_graphC_order_fp32.tflite |
decoder_norm + order head + global pointer。0.49 MB |
preprocessor_config.json |
上游官方预处理参数(800×800 resize + /255) |
samples/ 内容(4 页真实试卷,阈值 0.40,见 §7.7):
| 文件 | 说明 |
|---|---|
sample_*.jpg |
检测叠加:框 + 类别 + 置信度 + 阅读顺序编号 #1..#N |
coverage_*.jpg |
覆盖率可视化:红 = 没有任何框覆盖的墨迹(一眼看出漏没漏字) |
input_*.jpg |
原始试卷页(缩到宽 1600),对照用 |
compare_*.jpg |
左 = 全 CPU(f32 逐位正确)/ 右 = 推荐 GPU 车道 |
detections.json |
每页完整检出(框 / 类别 / 分数 / 阅读顺序)+ 阈值扫描表 + 覆盖率 |
完整性校验(sha256):
35e56da5b52e2f4ecf2c3d23c6c37ac959808b0c69f1c0b0978371e0c8d0922a pplayoutv3_graphA_enc_fp32.tflite
843b4e51f5743850f077574bf31fb675a1e8ab94988f1ac355adc6c887b04945 pplayoutv3_graphM_maskbranch_fp32.tflite
7f58e9d3c2846b01dd5587237c2f2bcc91703d6db65d07e93eccae27f687cc0d pplayoutv3_graphB2_dec_fp32.tflite
6a67787dca735aacdc77f07d389c416187347f88e478774de790321c06ca0efc pplayoutv3_graphC_order_fp32.tflite
不需要 tokenizer / 词表 —— 这是一个纯视觉检测+分割模型。
3. 输入规格
name : serving_default_args_0
shape: [1, 3, 800, 800]
dtype: float32
布局 : NCHW(**不要**改成 channel-last 传入)
前处理:
bgr -> rgb
cv2.resize((800, 800), interpolation=INTER_LINEAR) # keep_ratio = false,强制拉伸
/ 255.0
转 NCHW
坐标缩放:模型输出位于 800×800 空间,需自行乘 (W_orig/800, H_orig/800) 回到原图。
实测注意:必须用 0–1 归一化输入。实测 0–255 输入时得分崩到 0.05(正确约 0.96)。
4. 输出规格与串联顺序
4.1 数据流
pixel_values [1,3,800,800] float32
│
┌───────────────────▼───────────────────────────┐
│ GraphA encoder 后端 kGpu | kCpu │ 107.01 MB
└───────────────────┬───────────────────────────┘
│ enc_outputs_class [1,13125,25]
│ output_memory [1,13125,256]
│ source_flatten [1,13125,256]
│ mask_feat [1,32,200,200]
┌───────────────────▼───────────────────────────┐
│ HOST top-k(13125 → 300) + gather │ 纯 host,< 1 ms
└───────────────────┬───────────────────────────┘
│ target [1,300,256]
┌───────────────────▼───────────────────────────┐
│ GraphM 掩码分支 后端 **kCpu(必须)** │ 0.58 MB
└───────────────────┬───────────────────────────┘
│ init_reference_points [1,300,4]
┌───────────────────▼───────────────────────────┐
│ GraphB2 decoder 后端 kGpu │ 28.33 MB
└───────────────────┬───────────────────────────┘
│ logits [1,300,25]
│ pred_boxes [1,300,4]
│ oq_raw [1,300,256] ← **未归一化**
│ out_masks [1,300,200,200]
┌───────────────────▼───────────────────────────┐
│ GraphC LN + order head 后端 **kCpu(必须)** │ 0.49 MB
└───────────────────┬───────────────────────────┘
│ order_logits [1,300,300]
▼
官方 postprocessor → 框 / 类别 / 掩码 / 阅读顺序
4.2 各图 I/O
GraphA in : serving_default_args_0 [1, 3, 800, 800] f32
out: output_0 enc_outputs_class [1, 13125, 25] f32
output_1 output_memory [1, 13125, 256] f32
output_2 source_flatten [1, 13125, 256] f32
output_3 mask_feat [1, 32, 200, 200] f32
GraphM in : args_0 target [1, 300, 256]
args_1 mask_feat [1, 32, 200, 200]
out: output_0 init_reference_points [1, 300, 4]
GraphB2 in : args_0 target [1, 300, 256]
args_1 source_flatten [1, 13125, 256]
args_2 mask_feat [1, 32, 200, 200]
args_3 init_reference_points [1, 300, 4]
out: output_0 logits [1, 300, 25] # 需自行 sigmoid
output_1 pred_boxes [1, 300, 4] # **归一化 cxcywh**
output_2 oq_raw [1, 300, 256] # 未归一化的 last_hidden_state
output_3 out_masks [1, 300, 200, 200] # 掩码 logits
GraphC in : args_0 oq_raw [1, 300, 256]
out: output_0 order_logits [1, 300, 300]
⚠️
oq_raw不是oq_last。它是 decoder 最后一层未经 LayerNorm 的隐藏状态,decoder_norm被刻意放在 GraphC 里(CPU/fp32)执行 —— 因为 LayerNorm 的 mean/var 归约 在 fp16 下丢精度,实测该张量在 GPU/CPU 间差max|d| = 7.4(本征量级 O(1))。 不要把它直接喂给 order head。
⚠️ two-stage 的 top-k 不在图里 —— 必须自己在
enc_outputs_class上做topk(max(-1).values, 300),注意是取 13125 个位置的 top-300,不是 25 类里的。
5. 后端约束(必须遵守)
| 图 | 推荐后端 | 为什么 |
|---|---|---|
| GraphA | kGpu | kCpu |
encoder 是重头:CPU 1502 ms → GPU 40 ms。允许回退 |
| GraphM | kCpu(必须) |
❗ GPU 上会产出 inf,见下 |
| GraphB2 | kGpu |
CPU 983 ms → GPU 90 ms |
| GraphC | kCpu(必须) |
❗ global pointer 的 64 维点积在 fp16 下溢出(order_logits 值域 [−1e4, 7769],75% 的分量是 masked_fill 填的 −1e4),sigmoid 全饱和 → 阅读顺序错乱 |
为什么 GraphM 一定不能上 GPU
掩码分支里有一个离散放大点:
enc_out_masks = bmm(mask_query_head(oq), mask_feat.flatten(2)) [1,300,200,200]
└─ 1200 万次「> 0」硬阈值比较
fp16 噪声翻转掩码边界像素(实测 130/300 个 query 的掩码面积变了)
→ mask_to_box_coordinate 得到的框退化(贴边 / 零面积)
→ inverse_sigmoid = log(x/(1-x)) 冲到 ±11.5,某些位置到 ±inf
→ decoder 第 0 层的 reference_points = sigmoid(init_ref) 饱和到 {0,1}
→ deformable attention 采样全部落到图外 → 第 0 层就崩
这是离散放大,不是累积舍入误差,所以线性手段(safe LayerNorm、把 LayerNorm 挪到 fp32) 对它完全无效。实测证据:
| 度量 | 值 |
|---|---|
GraphM 单独在 GPU 上,init_ref 的 GPU/CPU 偏差 |
max|d| = inf(14 个非有限元素,34/1200 个 |d| > 1) |
官方 check_gpu_compatibility 对 GraphM 的结论 |
"outputs diverge from CPU (max abs diff inf)" |
| decoder 第 0 层 hidden state 的 GPU/CPU 偏差(掩码分支在 GPU 时) | max|d| = 5.233(本征 |x|max = 14.29) |
代价很小:GraphM 只有 0.58 MB,CPU 上 60 ms。不要为了"全 GPU"把它塞回去。
6. 转换流程
6.1 思路(为什么这么走)
-
车道选择:
.litertlm是 LLM/VLM 专用容器(TfLiteModelType枚举穷举 15 种,无检测类), 且官方PaddleOCR-VL-1.6.litertlm自己就不含 layout 模型。 → 走官方.tflite+ host loop 车道,工具用litert-torch(不是onnx2tf—— 后者会破坏 ViT attention)。 -
起点选择:
transformers >= 5.17内置原生 PyTorch 实现 (PPDocLayoutV3ForObjectDetection)。相比从 Paddle ONNX 起步,原生路线省掉整层 ONNX-isms 和torch.export的 FakeTensor 墙,且能直接改源码。 → 换路前先做等价验证:同输入喂两边,逐框 IoU 21/21,min IoU = 1.0000。 -
核心难点 1 ——
grid_sample(可变形注意力,18 个)TFLite 里没有这个 builtin 算子, 转换器只能手工展开 → 实测占整图 62.7% 节点、产生 440 个 rank 5/6 张量、 以及全部被官方 delegate 拒绝的算子(点名的 223 条里 216 条 INT64 CAST 落在 GridSample 内)。 -
重写策略:把「按坐标取数」换成「乘权重矩阵」。采用可分离 tent-matmul + 注意力折进权重矩阵:
wx = relu(1 - |ix - xs|) # xs = arange(W) wy = relu(1 - |iy - ys|) M[n,q,h,w] = Σ_p attn[n,q,p] · wy[n,q,p,h] · wx[n,q,p,w] # 4D out = matmul(value[N,C,hw], M[N,hw,q]) # 一次 BMM
- relu 天然实现
padding_mode='zeros'的越界补零 - 不构造采样网格 → rank 5/6 自动消失
- 算力约 +8%,最大中间量
[N,Q,h,w]= 24M 元素 - 数值与原始实现逐头 corr = 1.00000000
- relu 天然实现
-
核心难点 2 —— 按数值敏感度切图。逐层二分(把中间张量都开成 graph output, GPU/CPU 各跑一遍)后发现 decoder 第 0 层就起跳
max|d| = 5.233, 说明问题在输入侧,顺着往回查找到掩码分支的硬阈值(见 §5)。 于是切成四张图,把两个"离散/溢出"段强行留在 CPU。
6.2 迭代结果(每一轮都用官方 gate 量化)
| 轮次 | 做法 | tensors >4D | max_ndim | 被拒算子 | GPU 编译 |
|---|---|---|---|---|---|
| A | Paddle ONNX Graph A(对照) | 440 | 6 | 223 | ✗ 失败 |
| B2 | 原生 + 官方 patch_grid_sample |
146 | 6 | 160 | ✗ 失败 |
| B3 | B2 + 掩码头只算最后一层 | 144 | 6 | 160 | ✗ 失败 |
| C | 可分离 tent + 注意力折进权重矩阵 | 0 | 4 | 22 | ✗ 失败(RELU_0_TO_1) |
| C+ | C + clamp(0,1) → relu(x)-relu(x-1) |
0 | 4 | — | ✓ 成功 |
| 四图 | C+ 切分 + 掩码分支搬 CPU | 0 | 4 | GraphB2 = NONE | ✓ 四图全部满驻留 |
6.3 途中的四个关键工程坑
| 坑 | 现象 | 修法 |
|---|---|---|
fp64 的 tfl.pow |
failed to legalize operation 'tfl.pow' : (tensor<f64>, ...) |
AIFI 的 sine position embedding 内部用 temperature**omega(fp64),TFLite 无 float64 → 冻成常量(输入固定 800×800,它本就是常量) |
2x − 1 遗漏 |
logits corr 只有 0.719 |
参考实现里 sampling_locations 是 [0,1] 坐标,送进采样前会 2x-1 变成 [-1,1] |
RELU_0_TO_1 |
Not supported op,GPU-only 编译失败 |
来自 inverse_sigmoid() 的 x.clamp(0,1) → 官方 rewrite relu(x) - relu(x-1),精确等价 |
| anchors 的子图 | BROADCAST_TO / SELECT_V2 被 delegate 拒绝 |
generate_anchors 只依赖静态 shape → 预热后冻成常量 |
以及一个数学陷阱(写错了很难发现):
Σ_p a_p·u_p·v_p ≠ (Σ_p a_p·u_p)(Σ_p a_p·v_p)
即注意力权重不能折进两顶一维帐篷,必须折进二维权重矩阵。错误形式的实测误差
max|d| = 0.77,正确形式 1.8e-07。
7. 验证结果
7.1 类别表(25 类,按 index 顺序)
0 abstract · 1 algorithm · 2 aside_text · 3 chart · 4 content · 5 display_formula
6 doc_title · 7 figure_title · 8 footer · 9 footer_image · 10 footnote
11 formula_number · 12 header · 13 header_image · 14 image · 15 inline_formula
16 number · 17 paragraph_title · 18 reference · 19 reference_content · 20 seal
21 table · 22 text · 23 vertical_text · 24 vision_footnote
7.2 切分本身的误差:零
四张图在 PyTorch 层串起来 vs 原始模型:
logits corr = 1.0000000 max|d| = 1.116e-04
pred_boxes corr = 1.0000000 max|d| = 2.295e-05
order_logits corr = 1.0000000 max|d| = 2.65e-01
out_masks corr = 1.0000000 max|d| = 2.241e-01
order_seq 逐位一致: True (21/21)
→ 所有差异都只来自 TFLite 的 fp16 执行,不来自切分。
7.3 官方 GPU gate(每张图)
| 图 | gpu_compile_ok |
gpu_cpu_fallback |
max_abs_diff vs CPU |
|---|---|---|---|
| GraphA | ✅ True | False | 4.482e-02 |
| GraphM | ✅ True | False | inf(因此必须 CPU) |
| GraphB2 | ✅ True | False | 2.162e+02 |
| GraphC | ✅ True | False | 4.227e+00(因此必须 CPU) |
四张图 gpu_compile_ok=True 且 gpu_cpu_fallback=False ⇒ 满驻留
(GPU 编译成功、无静默回退 CPU —— 这是"数值对齐有意义"的前提)。
max_abs_diff 只作参考:官方自己的口径是
"correlation is not the ship criterion, real-image detection IoU is",
而且全表 max_abs_diff 会被不会被采用的低分 query主导。任务级见 §7.4。
7.4 任务级精度:24 页真实试卷 A/B
基准 = 全 CPU fp32(已逐位验证 == PyTorch)。共 581 个参考框。 阈值 0.5(当时的口径;推荐值已改为 0.40,见 §7.6 —— 该表用于量化"后端落点的代价", 换阈值会等比放大框数,但各车道之间的相对关系不变)。
| 车道 | p50 | p90 | 框匹配 | τ_mean | τ_min | 阅读顺序完全相同 | 加速比 |
|---|---|---|---|---|---|---|---|
| 全 CPU(基准) | 2546.9 ms | 2555.1 ms | 100.0% | 1.0000 | 1.0000 | 24/24 页 | 1.0× |
| A_gpu | 1073.8 ms | 1146.5 ms | 99.7% | 0.9998 | 0.9977 | 21/24 页 | 2.4× |
| A+B_gpu(推荐) | 185.9 ms | 189.3 ms | 97.9% | 0.9998 | 0.9977 | 22/24 页 | 13.7× |
| A+M+B_gpu(极限) | 138.7 ms | 140.8 ms | 97.4% | 0.9993 | 0.9939 | 20/24 页 | 18.4× |
逐级耗时拆解(lishi_0548.jpg,4243×3000):
| 车道 | A(enc) | M(mask) | B(dec) | C(order) | 合计 |
|---|---|---|---|---|---|
| 全 CPU | 1502 ms | 60 ms | 984 ms | 1 ms | 2547 ms |
| A+B_gpu | 42 ms | 59 ms | 95 ms | 1 ms | 197 ms |
注:GraphB2 只有 28 MB,GPU 上却要 90 ms(比 107 MB 的 GraphA 的 40 ms 还慢)—— deformable attention 的可分离 tent 采样是
[32,40000]×[40000,300]级别的大矩阵乘, 6 层 × 8 头,是 FLOP 密集型。这也解释了为什么"把 B 换 CPU"要 983 ms。
7.5 阅读顺序的判据(一个很容易踩的坑)
官方 order_seq 的形状是 [300] ——它是全部 300 个 query 上的排名
(argsort(votes) → scatter_ 成 arange(300)),最后才 gather 给被选中的框,
并没有重编号到 0..n-1:
order_pointers = torch.argsort(order_votes, dim=1) # [300]
order_seq.scatter_(1, order_pointers, ranks) # ranks = arange(300)
...
order_seq = order_seq.gather(dim=1, index=index) # index = 被选中的 query 号
所以只要有任何一个未被选中的 query 的 votes 漂移,保留下来的框的排名值就会整体平移, 即使它们彼此的先后完全没变。用"排名逐位一致"当判据会出现假警报:
| 页面 | 框数 | 旧口径:排名逐位一致 | 新口径:Kendall τ | 新口径:排序后序列相同 |
|---|---|---|---|---|
| lishi_0548 | 21 | 3/21 ~ 9/21 | 1.0000 | 是 |
| shuxue_1805 | 49 | 5/49 ~ 30/49 | 0.9939 ~ 1.0000 | 2 条车道为否 |
| shuxue_0670 | 44 | 10/44 ~ 22/44 | 0.9979 ~ 1.0000 | 1 条车道为否 |
判据建议:跨实现/跨后端比较一律用 保留框之间的相对顺序 (Kendall τ + "按 order 排序后的检出声序列是否相同"); "逐位排名一致"只用于同一条流水线内部的回归。
7.6 ★ 阈值:不要沿用官方默认的 0.5(会漏字)
官方 inference.yml 里是 draw_threshold: 0.5,但实测在语文试卷页上这个阈值会漏掉一半以上的版面内容。
度量方式:ink = 灰度<128 的像素(文字/线条),
覆盖率 = 落在任一个框(外扩 4px)内的 ink / 全部 ink。
未被覆盖的 ink 就是真正漏掉的字。
| 阈值 | yuwen_0367(语文,双栏,8→12 框) | shuxue_1805 | shuxue_0670 | lishi_0548 |
|---|---|---|---|---|
| 0.50(官方默认) | 8 框 · 57.42% ❌ | 49 框 · 99.65% | 44 框 · 99.98% | 21 框 · 97.47% |
| 0.45 | 10 框 · 70.92% | 53 框 · 99.86% | 45 框 · 99.98% | 21 框 · 97.47% |
| 0.40(推荐) | 12 框 · 99.76% ✅ | 57 框 · 99.98% ✅ | 47 框 · 99.98% ✅ | 21 框 · 97.47% |
| 0.35 | 15 框 · 99.98% | 57 框 · 99.98% | 53 框 · 99.98% | 21 框 · 97.47% |
| 0.30 | 17 框 · 99.98% | 62 框 · 99.98% | 58 框 · 99.98% | 21 框 · 97.47% |
| 0.25 | 19 框 · 100.00% | 70 框 · 99.98% | 65 框 · 99.98% | 21 框 · 97.47% |
0.40 是覆盖率拐点:从 0.50 → 0.45 → 0.40,覆盖率 57% → 71% → 99.76%,之后基本饱和 (再加框只增加冗余、不再增加覆盖)。所以本仓库的样本图与推荐用法都用 0.40。
漏掉的是什么:yuwen_0367 在 0.5 下漏掉的是右栏中部的整段内容
(第 3 题题干 + A/B/C/D 四个选项 + 【考点】27 病句辨析),对应两个 text 块,
分数分别是 0.492 / 0.469 —— 就差 0.008 和 0.031 没够到 0.5。
lishi_0548稳定在 97.47% 与阈值无关,未覆盖的是页眉的页码行(模型没把它当header)。 这是模型的固有行为,不是阈值问题。
samples/coverage_*.jpg 就是这套度量的可视化(红 = 未覆盖墨迹)。
7.7 端到端(4 页,本仓库 samples/ 里的就是这几页,阈值 0.40)
| 图片 | 尺寸 | 检出(CPU/GPU) | 墨迹覆盖率 | 全 CPU | 推荐 GPU 车道 | 阅读顺序 |
|---|---|---|---|---|---|---|
| lishi_0548 | 4243×3000 | 21 / 21 | 97.47% | 3044 ms | 406 ms | τ=1.0000,0 逆序对 |
| shuxue_0670 | 2122×3000 | 47 / 48 | 99.98% | 2577 ms | 187 ms | τ=1.0000,0 逆序对 |
| shuxue_1805 | 4247×3000 | 57 / 56 | 99.98% | 2548 ms | 182 ms | τ=1.0000,0 逆序对 |
| yuwen_0367 | 4247×3000 | 12 / 12 | 99.76% | 2548 ms | 185 ms | τ=1.0000,0 逆序对 |
(GPU 车道的框数与 CPU 差 ±1,丢的是刚好卡在阈值上的那个; 匹配上的框之间阅读顺序完全一致。)
7.8 三方交叉验证:HF 原生 ≡ Paddle ONNX ≡ 本仓库 tflite
同一张 yuwen_0367,三个实现(transformers 原生 PyTorch / Paddle 官方导出的
inference_static_padfix.onnx / 我们的四图 tflite)在每一个阈值上框数完全相同:
| 阈值 | 0.50 | 0.45 | 0.40 | 0.35 | 0.30 | 0.25 | 0.20 | 0.15 | 0.10 |
|---|---|---|---|---|---|---|---|---|---|
| HF 原生 | 8 | 10 | 12 | 15 | 17 | 19 | 20 | 22 | 30 |
| Paddle ONNX | 8 | 10 | 12 | 15 | 17 | 19 | 20 | 22 | 30 |
| 本仓库 tflite | 8 | 10 | 12 | 15 | 17 | 19 | 20 | 22 | 30 |
@0.5 时三方逐框匹配 8/8,min IoU = 1.0000,类别 8/8; 渲染图逐像素一致。
→ "漏字"与转换无关,是阈值 + 模型粒度的问题。
8. 已知限制
| # | 限制 | 说明 |
|---|---|---|
| 1 | GPU 数字来自宿主机 WebGPU,不是 MTK | 本文所有 GPU 数据是 WebGPU/Dawn delegate 上测的;MTK Android 上跑的是 OpenCL 后端,fp16 存储策略可能不同 → 上机后必须按 §7.5 的口径重跑 A/B |
| 2 | 阈值必须下调到 0.40 | 官方默认 0.5 在双栏语文卷上只覆盖 57.42% 的墨迹(§7.6)。降到 0.40 后 4 页覆盖率 97.5–99.98%。代价是框数增加 8~15%,是否会引入 FP 未用标注评测过 |
| 3 | GPU 车道框数比 CPU 少约 1% | 24 页 581 框中丢 12 个(@0.5)。丢的都在阈值附近,靠下调阈值可补回(@0.40 时 4 页只差 ±1 个) |
| 4 | GraphA 仍有 1 个 GELU |
未阻止满驻留(gpu_compile_ok=True)。可按官方 rewrite 表用 tanh 近似展开,但会引入新的数值偏差 |
| 5 | 样本量只有 24 页 | τ_min 出现的页面集中在 shuxue_*。上线前建议扩到 100+ 页,并覆盖双栏 / 竖排 / 图文混排 |
| 6 | GraphM 的 > 0 阈值是原模型语义 |
它是精确的整数运算,不能为了 fp16 友好而改(改了就不是同一个模型)。正确做法只能是保持在 fp32 执行 |
| 7 | 后处理未提供 C++ 实现 | 本仓库只提供模型;host 侧需自行移植官方 postprocessor |
| 8 | CPU 延迟只在 x86 主机上测过 | 手机 ARM 预计 5–15 s/页(未实测) |
| 9 | layout 是块级模型,不是行级 | 它输出的是"版面块"(段落/标题/公式/表格),一个 text 块里可能包含多行甚至多道题。块内的字要靠 OCR(如 PP-OCRv5)去读,不要指望 layout 把每一行/每一个字都框出来 |
结论:CPU(XNNPACK)路径完整可用且数值逐位正确; GPU 路径已打通(满驻留 + 13.7× 加速),但真机未验证。
9. 最小使用示例(Python)
import cv2, numpy as np
from ai_edge_litert import compiled_model as cm
CPU, GPU = cm.HardwareAccelerator.CPU, cm.HardwareAccelerator.GPU
def load(path, accel):
m = cm.CompiledModel.from_file(path, hardware_accel=accel)
return m, m.create_input_buffers(0), m.create_output_buffers(0)
# 后端分配见 §5 —— GraphM / GraphC 必须是 CPU
gA, ai, ao = load("pplayoutv3_graphA_enc_fp32.tflite", GPU | CPU)
gM, mi, mo = load("pplayoutv3_graphM_maskbranch_fp32.tflite", CPU)
gB, bi, bo = load("pplayoutv3_graphB2_dec_fp32.tflite", GPU)
gC, ci, co = load("pplayoutv3_graphC_order_fp32.tflite", CPU)
bgr = cv2.imread("page.jpg"); H0, W0 = bgr.shape[:2]
x = cv2.resize(bgr[:, :, ::-1], (800, 800), interpolation=cv2.INTER_LINEAR)
x = np.ascontiguousarray(np.transpose(x.astype(np.float32) / 255.0, (2, 0, 1))[None])
# 1) GraphA
ai[0].write(x); gA.run_by_index(0, ai, ao)
enc_cls = ao[0].read(13125*25, np.float32).reshape(1, 13125, 25)
out_mem = ao[1].read(13125*256, np.float32).reshape(1, 13125, 256)
src = ao[2].read(13125*256, np.float32).reshape(1, 13125, 256)
mfeat = ao[3].read(32*200*200, np.float32).reshape(1, 32, 200, 200)
# 2) HOST top-k:注意是 13125 个位置上取 top-300
idx = np.argsort(-enc_cls.max(-1)[0])[:300]
target = out_mem[0, idx][None]
# 3) GraphM(CPU)
mi[0].write(target); mi[1].write(mfeat); gM.run_by_index(0, mi, mo)
init_ref = mo[0].read(300*4, np.float32).reshape(1, 300, 4)
# 4) GraphB2(GPU)
bi[0].write(target); bi[1].write(src); bi[2].write(mfeat); bi[3].write(init_ref)
gB.run_by_index(0, bi, bo)
logits = bo[0].read(300*25, np.float32).reshape(1, 300, 25)
boxes = bo[1].read(300*4, np.float32).reshape(1, 300, 4)
oq_raw = bo[2].read(300*256, np.float32).reshape(1, 300, 256)
masks = bo[3].read(300*200*200, np.float32).reshape(1, 300, 200, 200)
# 5) GraphC(CPU)—— 输入是未归一化的 oq_raw
ci[0].write(oq_raw); gC.run_by_index(0, ci, co)
order = co[0].read(300*300, np.float32).reshape(1, 300, 300)
# 6) 之后按 §10 做后处理
10. 主机侧后处理(必须做,模型不包含)
建议直接移植官方实现
(transformers/models/pp_doclayout_v3/image_processing_pp_doclayout_v3.py::post_process_object_detection)。
等价流程:
scores = sigmoid(logits) # [1,300,25]
# 1) ★ 先做「全局 top-300」:把 300×25 拉平成 7500,取前 300 个 (query, class) 对
scores, index = topk(scores.flatten(1), num_queries, dim=-1) # num_queries = 300
labels = index % 25
qidx = index // 25
boxes = boxes.gather(1, qidx)
masks = masks.gather(1, qidx)
order = order.gather(1, qidx)
# 2) 框:归一化 cxcywh -> xyxy,再 × (W, H, W, H)
cx, cy, w, h = split(pred_boxes, 2, dim=-1)
boxes = concat([cx - 0.5*w, cy - 0.5*h, cx + 0.5*w, cy + 0.5*h], -1)
boxes = boxes * stack([W, H, W, H])[None, None, :]
# 3) 掩码:sigmoid 后阈值二值化
masks = (sigmoid(out_masks) > threshold)
# 4) 阅读顺序:由 order_logits 算"投票"再转成排名
# 注意排名是在 300 个 query 上做的,不重编号到 0..n-1(见 §7.5)
os_ = sigmoid(order_logits)
votes = os_.triu(1).sum(-1) + (1 - os_.transpose(1,2)).tril(-1).sum(-1)
pointers = argsort(votes, dim=-1)
order_seq = empty_like(pointers); order_seq.scatter_(1, pointers, arange(300))
# 5) ★ 阈值是在 top-k【之后】才施加的
keep = scores >= threshold # ★ 推荐 0.40,不要用官方的 0.5(见 §7.6)
⚠️ 这一步顺序错了会得到完全错误的结果。 实测:如果写成"每 query 取
sigmoid(logits).max(-1) >= 0.5",会保留全部 300 个 query —— 因为 RT-DETR 里 "无目标" query 的 25 个 logits 都接近 0,sigmoid 后 ≈ 0.5,正好卡在阈值上。 结果是 300 个score = 0.501、框[0, 0, 0.1, 0.1]的垃圾框。正确顺序是 先全局 top-300、再阈值(与官方
post_process_object_detection一致, 我们实测该口径下与上游 Paddle 导出逐框 122/122 吻合)。
多边形(polygon_points)可由「掩码 → 轮廓提取(cv2.findContours)」得到,官方实现即如此。
11. 许可与致谢
- 上游权重:
PaddlePaddle/PP-DocLayoutV3(Apache-2.0) - 论文:RT-DocLayout: Real-Time End-to-End Document Layout Analysis with Reading Order in the Wild(ECCV 2026,arXiv:2606.23344)
- 转换工具链:
litert-torch+ 官方litert-samples/utilities/litert_gpu_toolkit - 测试样本来自自有试卷数据集(仅用于可视化展示)
- 本转换产物:Apache-2.0