PP-DocLayoutV3-LiteRT

0 downloads 0 likes on ModelScope updated

Model card

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 23 0
GraphM (mask branch) 584,268 B float32 81 · int32 14 · bool 4 0
GraphB2 (decoder) 28,332,012 B float32 1997 · int32 64 0
GraphC (order head) 494,676 B float32 36 · int32 9 · bool 3 0
合计 136,419,552 B float32 2732 · int32 110 · bool 7 = 2849 0

但请注意:官方规则是 "即使是 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 思路(为什么这么走)

  1. 车道选择:.litertlm 是 LLM/VLM 专用容器(TfLiteModelType 枚举穷举 15 种,无检测类), 且官方 PaddleOCR-VL-1.6.litertlm 自己就不含 layout 模型。 → 走官方 .tflite + host loop 车道,工具用 litert-torch(不是 onnx2tf —— 后者会破坏 ViT attention)。

  2. 起点选择:transformers >= 5.17 内置原生 PyTorch 实现 (PPDocLayoutV3ForObjectDetection)。相比从 Paddle ONNX 起步,原生路线省掉整层 ONNX-isms 和 torch.export 的 FakeTensor 墙,且能直接改源码。 → 换路前先做等价验证:同输入喂两边,逐框 IoU 21/21,min IoU = 1.0000。

  3. 核心难点 1 —— grid_sample(可变形注意力,18 个)TFLite 里没有这个 builtin 算子, 转换器只能手工展开 → 实测占整图 62.7% 节点、产生 440 个 rank 5/6 张量、 以及全部被官方 delegate 拒绝的算子(点名的 223 条里 216 条 INT64 CAST 落在 GridSample 内)。

  4. 重写策略:把「按坐标取数」换成「乘权重矩阵」。采用可分离 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
  5. 核心难点 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