---
title: PP-DocLayoutV3-LiteRT
canonical_url: "https://www.modelscope.cn/models/dr3334/PP-DocLayoutV3-LiteRT"
md_url: "https://www.modelscope.cn/models/dr3334/PP-DocLayoutV3-LiteRT.md"
repository: dr3334/PP-DocLayoutV3-LiteRT
last_updated: 2026-09-17
license: apache-2.0
base_model:
  - PaddlePaddle/PP-DocLayoutV3_safetensors
base_model_relation: finetune
downloads: 0
stars: 0
tags:
  - layout-analysis
  - document-layout
  - object-detection
  - instance-segmentation
  - litert
  - tflite
  - pp-doclayout
  - rtdetr
  - gpu
---

# PP-DocLayoutV3-LiteRT

> PP-DocLayoutV3-LiteRT - dr3334 在 ModelScope 开源的模型。PP-DocLayoutV3-LiteRT (fp32, 四图切分 + GPU 可用)

dr3334/PP-DocLayoutV3-LiteRT 是 ModelScope 魔搭社区上的机器学习模型，采用 apache-2.0 许可，基于 PaddlePaddle/PP-DocLayoutV3_safetensors 构建。

- **Repository**: dr3334/PP-DocLayoutV3-LiteRT
- **License**: apache-2.0
- **Base model**: PaddlePaddle/PP-DocLayoutV3_safetensors
- **Tags**: layout-analysis, document-layout, object-detection, instance-segmentation, litert, tflite, pp-doclayout, rtdetr, gpu
- **Downloads**: 0
- **Stars**: 0
- **Last updated**: 2026-09-17

Source: https://www.modelscope.cn/models/dr3334/PP-DocLayoutV3-LiteRT

---

# 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 后端约束](#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`**：

```python
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）

```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`）。
等价流程：

```python
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
