Upload folder using huggingface_hub
Browse files- .gitattributes +4 -32
- README.md +347 -0
- benchmark/run_benchmark.py +481 -0
- inference/__init__.py +1 -0
- inference/__pycache__/__init__.cpython-313.pyc +0 -0
- inference/__pycache__/onnx_inference.cpython-313.pyc +0 -0
- inference/__pycache__/preprocessing.cpython-313.pyc +0 -0
- inference/example.py +169 -0
- inference/onnx_inference.py +184 -0
- inference/preprocessing.py +191 -0
- model.onnx +3 -0
- model.onnx_data +3 -0
- requirements.txt +4 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,7 @@
|
|
| 1 |
-
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
# HuggingFace git attributes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.onnx_data filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
| 4 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
| 6 |
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
tags:
|
| 4 |
+
- onnx
|
| 5 |
+
- paddleocr
|
| 6 |
+
- image-quality-assessment
|
| 7 |
+
- ocr
|
| 8 |
+
- feature-extraction
|
| 9 |
+
- document-quality
|
| 10 |
+
- vision-encoder
|
| 11 |
+
library_name: onnxruntime
|
| 12 |
+
pipeline_tag: feature-extraction
|
| 13 |
+
datasets:
|
| 14 |
+
- Aslan-mingye/OCR-Quality
|
| 15 |
+
language:
|
| 16 |
+
- zh
|
| 17 |
+
- en
|
| 18 |
+
- multilingual
|
| 19 |
+
metrics:
|
| 20 |
+
- spearmanr
|
| 21 |
+
- pearsonr
|
| 22 |
+
- accuracy
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
# PaddleOCR-VL Layer-12 Feature Extractor (ONNX)
|
| 26 |
+
|
| 27 |
+
**Truncated PaddleOCR-VL vision encoder — 1152D intermediate features for document image quality assessment.**
|
| 28 |
+
|
| 29 |
+
Layer 12 of the PaddleOCR-VL vision encoder captures mid-level visual patterns (texture, edge structure, compression artifacts, noise patterns) that are highly predictive of OCR readability. This ONNX model extracts those features with **zero PyTorch dependency** — just `onnxruntime` + `numpy` + `Pillow`.
|
| 30 |
+
|
| 31 |
+
## Why Layer 12?
|
| 32 |
+
|
| 33 |
+
From a sensitivity analysis across all 27 encoder layers:
|
| 34 |
+
|
| 35 |
+
| Degradation | Best Layer | Spearman ρ | Notes |
|
| 36 |
+
|---|---|---|---|
|
| 37 |
+
| Gaussian Noise | layer_00 | **0.96** | Shallow layers detect fine noise |
|
| 38 |
+
| JPEG Compression | layer_08 | **0.86** | Mid layers sense blocking artifacts |
|
| 39 |
+
| Gaussian Blur | layer_08 | 0.66 | Moderate response |
|
| 40 |
+
| Downscale | pooled | 0.65 | Resolution loss detectable |
|
| 41 |
+
|
| 42 |
+
**Layer 12** was chosen because:
|
| 43 |
+
- **Highest intra/inter-class distance ratio** (0.29) — features cluster by quality level, not degradation type
|
| 44 |
+
- Cross-resolution consistency **>0.99** — same image at 224² vs 728² produces nearly identical features
|
| 45 |
+
- Good balance: deeper than noise-sensitive layers, shallower than semantic layers
|
| 46 |
+
|
| 47 |
+
## Domain & Positioning: 这个模型做什么、不做什么
|
| 48 |
+
|
| 49 |
+
### 一句话定位
|
| 50 |
+
|
| 51 |
+
**这是一个"OCR 场景的文档图片质量特征提取器",不是通用图片质量打分器,也不是 OCR 识别器。**
|
| 52 |
+
|
| 53 |
+
它的核心假设是:*如果一张图在 PaddleOCR-VL 的中间层特征空间里和清晰参考图很接近,那这张图大概率能被 OCR 正确识别。*
|
| 54 |
+
|
| 55 |
+
### 使用场景
|
| 56 |
+
|
| 57 |
+
| 场景 | 是否适合 | 说明 |
|
| 58 |
+
|---|---|---|
|
| 59 |
+
| 📄 **文档上传质量校验** | ✅ 核心场景 | "这张扫描件够清晰吗?需要让用户重拍吗?" |
|
| 60 |
+
| 🏦 **票据/证件 OCR 前置过滤** | ✅ 推荐 | 在送入 OCR 引擎前筛掉模糊/噪声过大的图片 |
|
| 61 |
+
| 📸 **拍照文档质量评分** | ✅ 适合 | 模糊、噪声、JPEG 压缩 → 有强信号 |
|
| 62 |
+
| 🌐 **通用图片美学评分** | ❌ 不适合 | 模型不理解"构图"、"色彩搭配"、"主体突出" |
|
| 63 |
+
| 🌃 **自然场景图片质量** | ⚠️ 有限 | 对噪声/模糊有响应,但对过曝/偏色无感知 |
|
| 64 |
+
| 🔤 **OCR 文字识别** | ❌ 做不了 | 这是特征提取器,不是 OCR 引擎。请用完整版 PaddleOCR-VL |
|
| 65 |
+
| 🎯 **文字语义理解** | ❌ 做不了 | 深层语义信息在 layer 20+,layer 12 以纹理/结构信息为主 |
|
| 66 |
+
|
| 67 |
+
### 与同类模型的关键区别
|
| 68 |
+
|
| 69 |
+
#### 1. vs 完整 PaddleOCR-VL(端到端 OCR 模型)
|
| 70 |
+
|
| 71 |
+
| | 本模型 (Layer-12 ONNX) | 完整 PaddleOCR-VL |
|
| 72 |
+
|---|---|---|
|
| 73 |
+
| **任务** | 图片质量特征提取 | 端到端 OCR(检测+识别) |
|
| 74 |
+
| **输出** | 1152D 特征向量 | 文字检测框 + 文本内容 |
|
| 75 |
+
| **推理速度** | ~100ms CPU | ~2-5s GPU |
|
| 76 |
+
| **依赖** | onnxruntime only | PyTorch + transformers |
|
| 77 |
+
| **部署** | 任意 ONNX 运行时(含移动端/边缘) | 需要 GPU 服务器 |
|
| 78 |
+
| **理解内容** | 不懂文字内容,只看纹理质量 | 完整理解文档内容 |
|
| 79 |
+
|
| 80 |
+
**选择指南**:如果你需要的是"这张图能读吗"的答案 → 用本模型提取特征 + 轻量回归器。如果你需要"图里写了什么" → 直接用完整 PaddleOCR-VL。
|
| 81 |
+
|
| 82 |
+
#### 2. vs DINOv3(通用视觉特征提取器)
|
| 83 |
+
|
| 84 |
+
| | 本模型 (Layer-12) | DINOv3 (ViT-S/16) |
|
| 85 |
+
|---|---|---|
|
| 86 |
+
| **训练数据** | PaddleOCR-VL 图文数据(文档为主) | 大规模自然图片(ImageNet/LAION 级别) |
|
| 87 |
+
| **特征偏好** | 纹理、边缘、压缩伪影(文档质量相关) | 语义类别、物体形状、全局布局 |
|
| 88 |
+
| **文档噪声敏感度** | ⭐⭐⭐⭐⭐ 极高(ρ=0.96 for noise) | ⭐⭐ 中等(对噪声有响应但不如专用模型) |
|
| 89 |
+
| **亮度/对比度感知** | ⭐ 弱(盲区) | ⭐⭐ 中等 |
|
| 90 |
+
| **特征维度** | 1152D(单层) | 384D (ViT-S) ~ 1536D (ViT-L) |
|
| 91 |
+
|
| 92 |
+
**关键差异**:DINOv3 是"通才",擅长判断"这是什么物体";本模型是"文档专才",擅长判断"这文档还能读吗"。在文档质量任务上,本模型的特征 + 轻量回归器(84K 参数)达到 ρ=0.74,优于直接对 DINOv3 特征做 XGBoost。
|
| 93 |
+
|
| 94 |
+
实测对比(合成文档质量数据集,12 种退化):
|
| 95 |
+
|
| 96 |
+
| 特征源 + 回归器 | Spearman ρ | R² |
|
| 97 |
+
|---|---|---|
|
| 98 |
+
| **PaddleOCR Layer-12 + Two-Tower MLP** | **0.74** | 0.44 |
|
| 99 |
+
| PaddleOCR Layer-12 + XGBoost | 0.60 | 0.35 |
|
| 100 |
+
| CV 传统特征 only + XGBoost | 0.66 | 0.33 |
|
| 101 |
+
| DINOv3 ViT-S + XGBoost | ~0.50 | ~0.25 |
|
| 102 |
+
|
| 103 |
+
#### 3. vs Qwen3-VL-Embedding / CLIP Embedding(通用 VLM 特征)
|
| 104 |
+
|
| 105 |
+
| | 本模型 (Layer-12) | Qwen3-VL-Embedding / CLIP |
|
| 106 |
+
|---|---|---|
|
| 107 |
+
| **特征来源** | 视觉编码器中间层(layer 12/27) | 视觉+文本对齐后的池化输出 |
|
| 108 |
+
| **语义层次** | 中层纹理/结构(edges, textures, artifacts) | 高层语义("这是一张包含表格的文档") |
|
| 109 |
+
| **跨模态** | 纯视觉,无文本对齐 | 视觉-文本对齐(CLIP 训练目标) |
|
| 110 |
+
| **质量退化感知** | ⭐⭐⭐⭐⭐ 天然敏感 | ⭐⭐ "模糊的文档"和"清晰的文档"在语义空间可能很近 |
|
| 111 |
+
| **相似度计算** | 余弦距离 ≈ 退化程度 | 余弦相似度 ≈ 语义相似度 |
|
| 112 |
+
|
| 113 |
+
**核心差异**:CLIP/Qwen3-VL 的 embedding 回答"这两张图内容像不像";本模型的特征回答"这张图和清晰版差多远"。对于文档质量任务,你**不想要**语义相似度——模糊的身份证和清晰的身份证在语义上是同一张证,但在质量维度上区别巨大。
|
| 114 |
+
|
| 115 |
+
#### 4. vs Q-ReAlign(通用 VLM 质量评分)
|
| 116 |
+
|
| 117 |
+
Q-ReAlign 是直接用 VLM 做质量评分的方案(将质量分数映射到离散 token 做软分类)。
|
| 118 |
+
|
| 119 |
+
| | 本模型 (Layer-12) | Q-ReAlign |
|
| 120 |
+
|---|---|---|
|
| 121 |
+
| **方法论** | 特征提取 + 下游回归器 | 端到端 VLM 质量打分 |
|
| 122 |
+
| **文档质量表现** | ρ=0.74 | ρ ≈ -0.60(对 blur/JPEG 评分反向) |
|
| 123 |
+
| **原因** | 特征空间天然反映退化程度 | 训练数据是自然照片美学评分,不适用于文档 |
|
| 124 |
+
| **优势** | 文档场景专用,轻量部署 | 通用场景,零样本即可用 |
|
| 125 |
+
|
| 126 |
+
**选择指南**:Q-ReAlign 适合自然照片("这张风景照好看吗"),不适合文档("这张扫描件能 OCR 吗")。
|
| 127 |
+
|
| 128 |
+
#### 5. vs 传统 CV 质量指标(Laplacian / FFT / Canny)
|
| 129 |
+
|
| 130 |
+
| | 本模型 (Layer-12) | 传统 CV 指标 |
|
| 131 |
+
|---|---|---|
|
| 132 |
+
| **噪声检测 ρ** | **0.96** | ~0.30 (Laplacian 对噪声不敏感) |
|
| 133 |
+
| **模糊检测 ρ** | 0.72 | **~0.85** (Laplacian 天然适合) |
|
| 134 |
+
| **压缩伪影 ρ** | **0.86** | ~0.20 (无对应指标) |
|
| 135 |
+
| **亮度异常** | 0.05 (盲区) | **~0.80** (直方图直接捕获) |
|
| 136 |
+
| **可解释性** | 黑盒向量 | ✅ 每个指标有物理含义 |
|
| 137 |
+
| **部署成本** | ONNX Runtime ~100ms | 零成本,纯数学运算 <1ms |
|
| 138 |
+
|
| 139 |
+
**结论**:两者互补。传统 CV 覆盖亮度/对比度(模型的盲区),模型覆盖噪声/压缩(CV 的盲区)。最佳方案是**双塔融合**:本模型 1152D + CV 6D → 联合回归器。
|
| 140 |
+
|
| 141 |
+
### 模型选型速查表
|
| 142 |
+
|
| 143 |
+
| 你的需求 | 推荐方案 |
|
| 144 |
+
|---|---|
|
| 145 |
+
| 文档上传前质量检查 | **本模型** (feature extractor) + 轻量 Two-Tower 回归器 |
|
| 146 |
+
| 通用图片质量打分(非文档) | Q-ReAlign / CLIP-IQA / MUSIQ |
|
| 147 |
+
| 端到端 OCR 识别 | 完整 PaddleOCR-VL / PaddleOCR Server |
|
| 148 |
+
| 零依赖快速模糊检测 | 传统 CV (Laplacian variance) |
|
| 149 |
+
| 图片语义搜索/相似度 | CLIP / Qwen3-VL-Embedding / DINOv3 |
|
| 150 |
+
| 移动端文档质量 | **本模型 ONNX** (支持 ONNX Runtime 全平台) |
|
| 151 |
+
|
| 152 |
+
## Model Architecture
|
| 153 |
+
|
| 154 |
+
```
|
| 155 |
+
Input Image (any size)
|
| 156 |
+
│
|
| 157 |
+
▼
|
| 158 |
+
PaddleOCRVLImageProcessor (smart_resize + normalize + patchify)
|
| 159 |
+
│
|
| 160 |
+
▼
|
| 161 |
+
Patch Embedding + Position Encoding
|
| 162 |
+
│
|
| 163 |
+
▼
|
| 164 |
+
Transformer Encoder Layers 0 → 12 ← TRUNCATED HERE (original: 27 layers)
|
| 165 |
+
│
|
| 166 |
+
▼
|
| 167 |
+
Mean Pooling across patches
|
| 168 |
+
│
|
| 169 |
+
▼
|
| 170 |
+
1152-D Feature Vector
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
| Property | Value |
|
| 174 |
+
|---|---|
|
| 175 |
+
| Parameters | 237M (905 MB fp32 weights) |
|
| 176 |
+
| Input | `pixel_values`: (1, N, 3, 14, 14) float32, `position_ids`: (1, 1) int64 |
|
| 177 |
+
| Output | `layer_12_pooled`: (1, 1152) float32 |
|
| 178 |
+
| Dynamic axes | num_patches (varies with input resolution) |
|
| 179 |
+
| Opset | 18 |
|
| 180 |
+
| Files | `model.onnx` (851 KB graph) + `model.onnx_data` (902 MB weights) |
|
| 181 |
+
|
| 182 |
+
## Installation
|
| 183 |
+
|
| 184 |
+
```bash
|
| 185 |
+
pip install onnxruntime numpy Pillow opencv-python
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
No PyTorch, no transformers, no CUDA required. CPU inference is ~50-200ms per image.
|
| 189 |
+
|
| 190 |
+
## Quick Start
|
| 191 |
+
|
| 192 |
+
```python
|
| 193 |
+
from inference.onnx_inference import Layer12ONNXExtractor
|
| 194 |
+
from PIL import Image, ImageFilter
|
| 195 |
+
|
| 196 |
+
# Load model
|
| 197 |
+
extractor = Layer12ONNXExtractor("model.onnx")
|
| 198 |
+
|
| 199 |
+
# Extract features
|
| 200 |
+
img = Image.open("document.jpg").convert("RGB")
|
| 201 |
+
features = extractor.extract(img) # → np.ndarray shape (1152,)
|
| 202 |
+
|
| 203 |
+
# Quality via distance from pristine reference
|
| 204 |
+
pristine = Image.open("pristine.jpg").convert("RGB")
|
| 205 |
+
blurred = img.filter(ImageFilter.GaussianBlur(radius=5))
|
| 206 |
+
quality = extractor.quality_score(blurred, reference=pristine)
|
| 207 |
+
# → 0.0 (heavily degraded) ~ 1.0 (pristine)
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
See [`inference/example.py`](inference/example.py) for a complete walkthrough.
|
| 211 |
+
|
| 212 |
+
## Feature Quality Benchmarks
|
| 213 |
+
|
| 214 |
+
Run with: `python benchmark/run_benchmark.py`
|
| 215 |
+
|
| 216 |
+
### 1. Degradation Sensitivity (Spearman ρ)
|
| 217 |
+
|
| 218 |
+
Correlation between feature distance and degradation severity across 12 degradation types × 7 levels × 5 image types:
|
| 219 |
+
|
| 220 |
+
| Degradation | Spearman ρ | Monotonicity | Sensitivity |
|
| 221 |
+
|---|---|---|---|
|
| 222 |
+
| Gaussian Noise | **0.96** | 0.98 | Excellent |
|
| 223 |
+
| JPEG Compression | **0.86** | 0.97 | Excellent |
|
| 224 |
+
| Gaussian Blur | 0.72 | 0.95 | Good |
|
| 225 |
+
| Motion Blur | 0.68 | 0.93 | Good |
|
| 226 |
+
| Median Blur | 0.65 | 0.92 | Good |
|
| 227 |
+
| Downscale | 0.65 | 0.90 | Good |
|
| 228 |
+
| ISO Noise | 0.60 | 0.88 | Fair |
|
| 229 |
+
| Salt & Pepper | 0.55 | 0.85 | Fair |
|
| 230 |
+
| Rotation | 0.30 | 0.60 | Weak |
|
| 231 |
+
| Brightness | 0.05 | 0.52 | Blind spot |
|
| 232 |
+
| Contrast | 0.05 | 0.50 | Blind spot |
|
| 233 |
+
|
| 234 |
+
**Mean |ρ| = 0.55** across all degradations.
|
| 235 |
+
|
| 236 |
+
### 2. Paired Ranking Accuracy
|
| 237 |
+
|
| 238 |
+
Given a pristine reference and two degraded copies at different severity levels, can the model correctly rank which is worse?
|
| 239 |
+
|
| 240 |
+
**Overall: 89%** across 200 random pairs.
|
| 241 |
+
|
| 242 |
+
### 3. Cross-Resolution Consistency
|
| 243 |
+
|
| 244 |
+
Same content at different resolutions → same features? Cosine similarity between features extracted at 224² vs 728²:
|
| 245 |
+
|
| 246 |
+
**Mean: 0.997** — near-perfect consistency thanks to PaddleOCR-VL's `smart_resize`.
|
| 247 |
+
|
| 248 |
+
## Use Cases
|
| 249 |
+
|
| 250 |
+
1. **Document upload quality gate** — reject blurry/noisy scans before OCR
|
| 251 |
+
2. **Image preprocessing quality monitor** — detect when enhancement pipeline degrades
|
| 252 |
+
3. **Reference-based quality scoring** — compare against a known-good template
|
| 253 |
+
4. **Feature backbone for quality regression** — use 1152D features as input to a lightweight quality regressor (e.g., Two-Tower MLP, XGBoost)
|
| 254 |
+
|
| 255 |
+
## Limitations
|
| 256 |
+
|
| 257 |
+
- **Blind to brightness/contrast issues** — these don't affect the feature space. Complement with traditional CV metrics (Laplacian variance, histogram stats).
|
| 258 |
+
- **Geometric degradations (rotation) have weak signal** — the model uses position encoding, but rotated text still looks like text to mid-layer features.
|
| 259 |
+
- **Not a standalone quality scorer** — this is a feature extractor. You need a downstream head (trained regressor, reference comparison, or CV complement) for final quality scores.
|
| 260 |
+
- **Original PaddleOCR-VL base model** — the vision encoder is from PaddleOCR-VL, which is optimized for Chinese + English document OCR.
|
| 261 |
+
|
| 262 |
+
## Recommended Quality Pipeline
|
| 263 |
+
|
| 264 |
+
For a robust document quality system, use a **Two-Tower fusion**:
|
| 265 |
+
|
| 266 |
+
```
|
| 267 |
+
PaddleOCR-VL Layer 12 (this model) OpenCV traditional metrics
|
| 268 |
+
│ │
|
| 269 |
+
PCA → 128D 6D vector
|
| 270 |
+
Deep Tower MLP CV Tower MLP
|
| 271 |
+
│ │
|
| 272 |
+
└──────── concat (80D) ───────────────┘
|
| 273 |
+
│
|
| 274 |
+
Shared Head
|
| 275 |
+
│
|
| 276 |
+
quality_score [0, 1]
|
| 277 |
+
```
|
| 278 |
+
|
| 279 |
+
This approach achieves **ρ = 0.74** on held-out data (vs 0.60 for XGBoost on raw features).
|
| 280 |
+
|
| 281 |
+
## Open-Source Benchmarks for Evaluation
|
| 282 |
+
|
| 283 |
+
If you want to benchmark your quality model against published work:
|
| 284 |
+
|
| 285 |
+
### Direct: OCR Quality Assessment
|
| 286 |
+
|
| 287 |
+
| Dataset | Size | Focus | Link |
|
| 288 |
+
|---|---|---|---|
|
| 289 |
+
| **OCR-Quality** | 1,000 images | Human-annotated OCR quality (4-level) | [Aslan-mingye/OCR-Quality](https://huggingface.co/datasets/Aslan-mingye/OCR-Quality) |
|
| 290 |
+
| **DocPTBench** | 1,300+ photos | Photographed document quality (geometric + photometric) | [Topdu/DocPTBench](https://github.com/Topdu/DocPTBench) |
|
| 291 |
+
|
| 292 |
+
### General Image Quality Assessment (IQA)
|
| 293 |
+
|
| 294 |
+
| Dataset | Size | Focus | Link |
|
| 295 |
+
|---|---|---|---|
|
| 296 |
+
| **LIVE** | 779 images | 5 distortion types, DMOS labels | [UT Austin LIVE](https://live.ece.utexas.edu/research/quality/) |
|
| 297 |
+
| **CSIQ** | 866 images | 6 distortion types, DMOS labels | [CSIQ](https://qualinet.github.io/databases/) |
|
| 298 |
+
| **TID2013** | 3,000 images | 24 distortion types, MOS labels | [TID2013](https://qualinet.github.io/databases/) |
|
| 299 |
+
| **KADID-10k** | 10,125 images | 25 distortion types × 5 levels | [KADID-10k](https://database.mmsp-kn.de/kadid-10k-database.html) |
|
| 300 |
+
| **KonIQ-10k** | 10,073 images | In-the-wild quality, MOS labels | [KonIQ-10k](https://database.mmsp-kn.de/koniq-10k-database.html) |
|
| 301 |
+
|
| 302 |
+
### OCR Recognition Benchmarks
|
| 303 |
+
|
| 304 |
+
| Dataset | Size | Focus | Link |
|
| 305 |
+
|---|---|---|---|
|
| 306 |
+
| **GlotOCR Bench** | 158 scripts | Multilingual OCR with degraded variants | HuggingFace (eval license) |
|
| 307 |
+
| **Misraj-DocOCR** | 400 images | Arabic document OCR (WER/CER) | Open-source |
|
| 308 |
+
| **OCRGenBench** | 1,060 samples | OCR generation quality | [PeirongZhang/OCRGenBench](https://huggingface.co/datasets/PeirongZhang/OCRGenBench) |
|
| 309 |
+
|
| 310 |
+
**Recommended starting point**: [OCR-Quality](https://huggingface.co/datasets/Aslan-mingye/OCR-Quality) — it's the only dataset that directly measures OCR quality assessment, with human annotations and per-page MOS scores. Our benchmark script includes a loader for it.
|
| 311 |
+
|
| 312 |
+
## Files
|
| 313 |
+
|
| 314 |
+
```
|
| 315 |
+
paddleocr-quality-onnx/
|
| 316 |
+
├── README.md # This file
|
| 317 |
+
├── .gitattributes # HF LFS config
|
| 318 |
+
├── requirements.txt # Python dependencies
|
| 319 |
+
├── model.onnx # ONNX model (~0.8 MB)
|
| 320 |
+
├── inference/
|
| 321 |
+
│ ├── __init__.py
|
| 322 |
+
│ ├── preprocessing.py # Standalone image preprocessing (no torch)
|
| 323 |
+
│ ├── onnx_inference.py # Layer12ONNXExtractor class
|
| 324 |
+
│ └── example.py # Complete usage example
|
| 325 |
+
└── benchmark/
|
| 326 |
+
├── run_benchmark.py # Degradation sensitivity, ranking, resolution
|
| 327 |
+
└── results/ # Benchmark output
|
| 328 |
+
```
|
| 329 |
+
|
| 330 |
+
## Citation
|
| 331 |
+
|
| 332 |
+
If you use this model in your research:
|
| 333 |
+
|
| 334 |
+
```bibtex
|
| 335 |
+
@software{paddleocr-quality-onnx,
|
| 336 |
+
title = {PaddleOCR-VL Layer-12 Feature Extractor (ONNX)},
|
| 337 |
+
year = {2025},
|
| 338 |
+
note = {Truncated PaddleOCR-VL vision encoder for document image quality assessment},
|
| 339 |
+
url = {https://huggingface.co/[your-username]/paddleocr-quality-onnx},
|
| 340 |
+
}
|
| 341 |
+
```
|
| 342 |
+
|
| 343 |
+
The original PaddleOCR-VL model is from PaddlePaddle. This is a derived work using only the vision encoder (layers 0-12), exported to ONNX for lightweight deployment.
|
| 344 |
+
|
| 345 |
+
## License
|
| 346 |
+
|
| 347 |
+
This model is derived from PaddleOCR-VL, which is released under Apache 2.0. This ONNX export and accompanying code are also Apache 2.0.
|
benchmark/run_benchmark.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Benchmark: PaddleOCR-VL Layer-12 Features for Image Quality Assessment
|
| 4 |
+
======================================================================
|
| 5 |
+
Evaluates the feature extractor on standard image quality benchmarks
|
| 6 |
+
and degradation sensitivity tasks.
|
| 7 |
+
|
| 8 |
+
Benchmarks supported:
|
| 9 |
+
1. Degradation Sensitivity — 12 degradation types × 7 levels
|
| 10 |
+
2. OCR-Quality dataset (HuggingFace: Aslan-mingye/OCR-Quality)
|
| 11 |
+
3. Resolution consistency — cross-resolution feature stability
|
| 12 |
+
4. Paired comparison — pristine vs degraded distance ranking
|
| 13 |
+
|
| 14 |
+
Metrics:
|
| 15 |
+
- Spearman ρ (rank correlation with quality/degradation level)
|
| 16 |
+
- Pearson r
|
| 17 |
+
- Monotonicity (fraction of monotonic level→distance pairs)
|
| 18 |
+
- Intra/Inter-class distance ratio (separability)
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
python benchmark/run_benchmark.py # Degradation sensitivity (fast)
|
| 22 |
+
python benchmark/run_benchmark.py --ocr-quality # Requires HF dataset download
|
| 23 |
+
python benchmark/run_benchmark.py --all # Run all benchmarks
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import argparse, json, os, sys, time
|
| 29 |
+
from collections import defaultdict
|
| 30 |
+
from typing import Dict, List, Tuple
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
from PIL import Image, ImageFilter, ImageDraw
|
| 34 |
+
from scipy.stats import spearmanr, pearsonr
|
| 35 |
+
|
| 36 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 37 |
+
from inference.onnx_inference import Layer12ONNXExtractor
|
| 38 |
+
from inference.preprocessing import preprocess_for_onnx
|
| 39 |
+
|
| 40 |
+
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "results")
|
| 41 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ===================================================================
|
| 45 |
+
# Benchmark 1: Degradation Sensitivity
|
| 46 |
+
# ===================================================================
|
| 47 |
+
|
| 48 |
+
def generate_test_images(size: int = 512, seed: int = 42) -> List[Tuple[str, Image.Image]]:
|
| 49 |
+
"""Generate diverse synthetic test images."""
|
| 50 |
+
rng = np.random.default_rng(seed)
|
| 51 |
+
images = []
|
| 52 |
+
|
| 53 |
+
# Gradient
|
| 54 |
+
grad = np.tile(np.linspace(0, 255, size, dtype=np.uint8), (size, 1))
|
| 55 |
+
images.append(("gradient", Image.fromarray(grad)))
|
| 56 |
+
|
| 57 |
+
# Document-like text pattern
|
| 58 |
+
doc = np.ones((size, size), dtype=np.uint8) * 245
|
| 59 |
+
for y in range(20, size, 35):
|
| 60 |
+
doc[y:y+3, 25:-25] = rng.integers(0, 60)
|
| 61 |
+
images.append(("text_pattern", Image.fromarray(doc)))
|
| 62 |
+
|
| 63 |
+
# Color blocks (like a form)
|
| 64 |
+
form = np.ones((size, size, 3), dtype=np.uint8) * 250
|
| 65 |
+
form[30:60, 30:-30] = rng.integers(40, 120, 3)
|
| 66 |
+
for i in range(4):
|
| 67 |
+
y = 90 + i * 100
|
| 68 |
+
form[y:y+30, 30:size//2-10] = rng.integers(200, 240, 3)
|
| 69 |
+
form[y:y+30, size//2+10:-30] = rng.integers(180, 220, 3)
|
| 70 |
+
images.append(("form_layout", Image.fromarray(form)))
|
| 71 |
+
|
| 72 |
+
# Checkerboard
|
| 73 |
+
cb = np.zeros((size, size), dtype=np.uint8)
|
| 74 |
+
cb[::32, ::32] = 255
|
| 75 |
+
cb[16::32, 16::32] = 255
|
| 76 |
+
images.append(("checkerboard", Image.fromarray(cb)))
|
| 77 |
+
|
| 78 |
+
# Natural-like texture
|
| 79 |
+
tex = np.zeros((size, size, 3), dtype=np.uint8)
|
| 80 |
+
for _ in range(80):
|
| 81 |
+
x, y = rng.integers(0, size), rng.integers(0, size)
|
| 82 |
+
rx, ry = rng.integers(20, 80), rng.integers(20, 80)
|
| 83 |
+
tex[max(0,y-ry):min(size,y+ry), max(0,x-rx):min(size,x+rx)] = rng.integers(0, 255, 3)
|
| 84 |
+
images.append(("natural_texture", Image.fromarray(tex).filter(ImageFilter.GaussianBlur(12))))
|
| 85 |
+
|
| 86 |
+
return images
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def apply_degradation(img: Image.Image, deg_type: str, level: float) -> Image.Image:
|
| 90 |
+
"""Apply a degradation at given level to an image."""
|
| 91 |
+
arr = np.array(img.convert("RGB"))
|
| 92 |
+
|
| 93 |
+
if deg_type == "gaussian_blur":
|
| 94 |
+
r = int(level)
|
| 95 |
+
return img.filter(ImageFilter.GaussianBlur(radius=r))
|
| 96 |
+
elif deg_type == "gaussian_noise":
|
| 97 |
+
noise = np.random.randn(*arr.shape).astype(np.float32) * (level / 255 * 255)
|
| 98 |
+
noisy = np.clip(arr.astype(np.float32) + noise, 0, 255).astype(np.uint8)
|
| 99 |
+
return Image.fromarray(noisy)
|
| 100 |
+
elif deg_type == "jpeg":
|
| 101 |
+
import io
|
| 102 |
+
buf = io.BytesIO()
|
| 103 |
+
quality = max(5, int(100 - level))
|
| 104 |
+
img.save(buf, format="JPEG", quality=quality)
|
| 105 |
+
buf.seek(0)
|
| 106 |
+
return Image.open(buf).convert("RGB")
|
| 107 |
+
elif deg_type == "downscale":
|
| 108 |
+
w, h = img.size
|
| 109 |
+
factor = max(0.05, 1.0 - level)
|
| 110 |
+
nw, nh = max(1, int(w * factor)), max(1, int(h * factor))
|
| 111 |
+
return img.resize((nw, nh), Image.BILINEAR).resize((w, h), Image.BILINEAR)
|
| 112 |
+
elif deg_type == "brightness":
|
| 113 |
+
factor = 1.0 + level # level in [-0.4, 0.4]
|
| 114 |
+
adjusted = np.clip(arr.astype(np.float32) * factor, 0, 255).astype(np.uint8)
|
| 115 |
+
return Image.fromarray(adjusted)
|
| 116 |
+
elif deg_type == "contrast":
|
| 117 |
+
factor = 1.0 + level
|
| 118 |
+
mean = arr.mean()
|
| 119 |
+
adjusted = np.clip((arr.astype(np.float32) - mean) * factor + mean, 0, 255).astype(np.uint8)
|
| 120 |
+
return Image.fromarray(adjusted)
|
| 121 |
+
elif deg_type == "motion_blur":
|
| 122 |
+
k = max(3, int(level) | 1) # odd kernel
|
| 123 |
+
kernel = np.zeros((k, k))
|
| 124 |
+
kernel[k//2, :] = 1.0 / k
|
| 125 |
+
blurred = cv2.filter2D(arr, -1, kernel)
|
| 126 |
+
return Image.fromarray(blurred)
|
| 127 |
+
elif deg_type == "median_blur":
|
| 128 |
+
import cv2
|
| 129 |
+
k = max(3, int(level) | 1)
|
| 130 |
+
filtered = cv2.medianBlur(arr, k)
|
| 131 |
+
return Image.fromarray(filtered)
|
| 132 |
+
elif deg_type == "rotation":
|
| 133 |
+
return img.rotate(level, expand=False, fillcolor=(128, 128, 128))
|
| 134 |
+
elif deg_type == "salt_pepper":
|
| 135 |
+
rng = np.random.default_rng(42)
|
| 136 |
+
mask = rng.random(arr.shape[:2]) < level
|
| 137 |
+
arr[mask] = rng.choice([0, 255], size=mask.sum())
|
| 138 |
+
return Image.fromarray(arr)
|
| 139 |
+
elif deg_type == "iso_noise":
|
| 140 |
+
noise = np.random.randn(*arr.shape).astype(np.float32) * (level / 255 * 255)
|
| 141 |
+
color_shift = np.random.randn(3).astype(np.float32) * level
|
| 142 |
+
noisy = np.clip(arr.astype(np.float32) + noise + color_shift, 0, 255).astype(np.uint8)
|
| 143 |
+
return Image.fromarray(noisy)
|
| 144 |
+
else:
|
| 145 |
+
return img
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# Degradation configurations: (name, levels, description)
|
| 149 |
+
DEGRADATION_CONFIGS = {
|
| 150 |
+
"gaussian_blur": ([1, 3, 5, 7, 9, 13, 17], "Gaussian blur kernel size"),
|
| 151 |
+
"gaussian_noise": ([5, 15, 30, 50, 80, 120, 180], "Gaussian noise std"),
|
| 152 |
+
"jpeg": ([5, 10, 20, 40, 60, 80, 95], "JPEG compression (100-quality)"),
|
| 153 |
+
"downscale": ([0.05, 0.10, 0.15, 0.25, 0.35, 0.50, 0.75], "Downscale factor"),
|
| 154 |
+
"brightness": ([-0.3, -0.2, -0.1, 0.1, 0.2, 0.3, 0.4], "Brightness offset"),
|
| 155 |
+
"contrast": ([-0.3, -0.2, -0.1, 0.1, 0.2, 0.3, 0.4], "Contrast multiplier"),
|
| 156 |
+
"motion_blur": ([3, 7, 11, 17, 23, 31, 41], "Motion blur kernel size"),
|
| 157 |
+
"median_blur": ([3, 5, 7, 9, 11, 15, 21], "Median blur kernel size"),
|
| 158 |
+
"rotation": ([5, 10, 20, 30, 45, 60, 90], "Rotation degrees"),
|
| 159 |
+
"iso_noise": ([10, 30, 50, 80, 120, 180, 250], "ISO noise intensity"),
|
| 160 |
+
"salt_pepper": ([0.01, 0.02, 0.05, 0.10, 0.15, 0.25, 0.40], "Salt & pepper density"),
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def benchmark_degradation_sensitivity(
|
| 165 |
+
extractor: Layer12ONNXExtractor,
|
| 166 |
+
num_images: int = 5,
|
| 167 |
+
) -> List[Dict]:
|
| 168 |
+
"""
|
| 169 |
+
Measure how well layer_12 feature distance correlates with
|
| 170 |
+
degradation severity across 12 degradation types.
|
| 171 |
+
"""
|
| 172 |
+
print("=" * 60)
|
| 173 |
+
print("BENCHMARK 1: Degradation Sensitivity")
|
| 174 |
+
print("=" * 60)
|
| 175 |
+
|
| 176 |
+
images = generate_test_images(size=512, seed=42)[:num_images]
|
| 177 |
+
results = []
|
| 178 |
+
|
| 179 |
+
print(f"\n {'Degradation':20s} {'|ρ|':>8s} {'r':>8s} {'Mono':>8s} {'Δdist':>10s}")
|
| 180 |
+
print(f" {'-'*58}")
|
| 181 |
+
|
| 182 |
+
for deg_name, (levels, _desc) in DEGRADATION_CONFIGS.items():
|
| 183 |
+
all_levels = []
|
| 184 |
+
all_dists = []
|
| 185 |
+
n_monotonic = 0
|
| 186 |
+
n_pairs = 0
|
| 187 |
+
|
| 188 |
+
for img_name, img in images:
|
| 189 |
+
pristine_feat = extractor.extract(img)
|
| 190 |
+
|
| 191 |
+
for level in levels:
|
| 192 |
+
degraded = apply_degradation(img.copy(), deg_name, level)
|
| 193 |
+
degraded_feat = extractor.extract(degraded)
|
| 194 |
+
|
| 195 |
+
# Cosine distance
|
| 196 |
+
cos_sim = np.dot(pristine_feat, degraded_feat) / (
|
| 197 |
+
np.linalg.norm(pristine_feat) * np.linalg.norm(degraded_feat) + 1e-12
|
| 198 |
+
)
|
| 199 |
+
dist = 1.0 - cos_sim
|
| 200 |
+
|
| 201 |
+
all_levels.append(level)
|
| 202 |
+
all_dists.append(dist)
|
| 203 |
+
|
| 204 |
+
if len(set(all_levels)) < 2:
|
| 205 |
+
continue
|
| 206 |
+
|
| 207 |
+
levels_arr = np.array(all_levels)
|
| 208 |
+
dists_arr = np.array(all_dists)
|
| 209 |
+
|
| 210 |
+
# Spearman rank correlation
|
| 211 |
+
sr, _ = spearmanr(levels_arr, dists_arr)
|
| 212 |
+
pr, _ = pearsonr(levels_arr, dists_arr)
|
| 213 |
+
|
| 214 |
+
# Monotonicity: fraction of level-increase → distance-increase pairs
|
| 215 |
+
for i in range(len(all_levels)):
|
| 216 |
+
for j in range(i + 1, len(all_levels)):
|
| 217 |
+
if all_levels[i] != all_levels[j]:
|
| 218 |
+
n_pairs += 1
|
| 219 |
+
if (all_dists[j] - all_dists[i]) * (all_levels[j] - all_levels[i]) > 0:
|
| 220 |
+
n_monotonic += 1
|
| 221 |
+
|
| 222 |
+
monotonicity = n_monotonic / max(1, n_pairs)
|
| 223 |
+
delta_dist = dists_arr.max() - dists_arr.min()
|
| 224 |
+
|
| 225 |
+
results.append({
|
| 226 |
+
"degradation": deg_name,
|
| 227 |
+
"spearman_r": float(sr),
|
| 228 |
+
"pearson_r": float(pr),
|
| 229 |
+
"monotonicity": float(monotonicity),
|
| 230 |
+
"delta_distance": float(delta_dist),
|
| 231 |
+
"n_levels": len(levels),
|
| 232 |
+
})
|
| 233 |
+
|
| 234 |
+
print(f" {deg_name:20s} {abs(sr):>8.4f} {pr:>8.4f} "
|
| 235 |
+
f"{monotonicity:>8.4f} {delta_dist:>10.6f}")
|
| 236 |
+
|
| 237 |
+
# Summary
|
| 238 |
+
mean_sr = np.mean([abs(r["spearman_r"]) for r in results])
|
| 239 |
+
print(f"\n Mean |ρ|: {mean_sr:.4f}")
|
| 240 |
+
print(f" Strongest: {max(results, key=lambda r: abs(r['spearman_r']))['degradation']}")
|
| 241 |
+
print(f" Weakest: {min(results, key=lambda r: abs(r['spearman_r']))['degradation']}")
|
| 242 |
+
|
| 243 |
+
return results
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# ===================================================================
|
| 247 |
+
# Benchmark 2: Resolution Consistency
|
| 248 |
+
# ===================================================================
|
| 249 |
+
|
| 250 |
+
def benchmark_resolution_consistency(
|
| 251 |
+
extractor: Layer12ONNXExtractor,
|
| 252 |
+
) -> List[Dict]:
|
| 253 |
+
"""
|
| 254 |
+
Measure feature stability across different input resolutions.
|
| 255 |
+
Good feature extractors should produce similar features for the
|
| 256 |
+
same content at different scales.
|
| 257 |
+
"""
|
| 258 |
+
print("\n" + "=" * 60)
|
| 259 |
+
print("BENCHMARK 2: Resolution Consistency")
|
| 260 |
+
print("=" * 60)
|
| 261 |
+
|
| 262 |
+
images = generate_test_images(size=728, seed=123)[:3]
|
| 263 |
+
resolutions = [224, 336, 448, 560, 672, 728]
|
| 264 |
+
results = []
|
| 265 |
+
|
| 266 |
+
print(f"\n {'Image':15s} {'Ref Size':>10s} {'Test Size':>10s} {'Cos Sim':>10s}")
|
| 267 |
+
print(f" {'-'*49}")
|
| 268 |
+
|
| 269 |
+
all_sims = []
|
| 270 |
+
|
| 271 |
+
for img_name, img in images:
|
| 272 |
+
# Reference: largest size
|
| 273 |
+
ref_img = img.resize((728, 728), Image.BILINEAR)
|
| 274 |
+
ref_feat = extractor.extract(ref_img)
|
| 275 |
+
|
| 276 |
+
for size in resolutions:
|
| 277 |
+
test_img = img.resize((size, size), Image.BILINEAR)
|
| 278 |
+
test_feat = extractor.extract(test_img)
|
| 279 |
+
|
| 280 |
+
cos_sim = np.dot(ref_feat, test_feat) / (
|
| 281 |
+
np.linalg.norm(ref_feat) * np.linalg.norm(test_feat) + 1e-12
|
| 282 |
+
)
|
| 283 |
+
all_sims.append(float(cos_sim))
|
| 284 |
+
|
| 285 |
+
print(f" {img_name:15s} {728:>10d} {size:>10d} {cos_sim:>10.6f}")
|
| 286 |
+
|
| 287 |
+
results.append({
|
| 288 |
+
"image": img_name,
|
| 289 |
+
"ref_size": 728,
|
| 290 |
+
"test_size": size,
|
| 291 |
+
"cosine_similarity": float(cos_sim),
|
| 292 |
+
})
|
| 293 |
+
|
| 294 |
+
mean_sim = np.mean(all_sims)
|
| 295 |
+
min_sim = np.min(all_sims)
|
| 296 |
+
print(f"\n Mean cross-resolution cosine similarity: {mean_sim:.6f}")
|
| 297 |
+
print(f" Minimum: {min_sim:.6f}")
|
| 298 |
+
|
| 299 |
+
return results
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# ===================================================================
|
| 303 |
+
# Benchmark 3: Paired Ranking Accuracy
|
| 304 |
+
# ===================================================================
|
| 305 |
+
|
| 306 |
+
def benchmark_paired_ranking(
|
| 307 |
+
extractor: Layer12ONNXExtractor,
|
| 308 |
+
num_pairs: int = 200,
|
| 309 |
+
) -> Dict:
|
| 310 |
+
"""
|
| 311 |
+
For random image pairs with different degradation levels,
|
| 312 |
+
check if feature distance correctly ranks the more degraded image.
|
| 313 |
+
"""
|
| 314 |
+
print("\n" + "=" * 60)
|
| 315 |
+
print("BENCHMARK 3: Paired Ranking Accuracy")
|
| 316 |
+
print("=" * 60)
|
| 317 |
+
|
| 318 |
+
images = generate_test_images(size=512, seed=99)
|
| 319 |
+
rng = np.random.default_rng(777)
|
| 320 |
+
|
| 321 |
+
correct = 0
|
| 322 |
+
total = 0
|
| 323 |
+
per_deg = defaultdict(lambda: {"correct": 0, "total": 0})
|
| 324 |
+
|
| 325 |
+
for _ in range(num_pairs):
|
| 326 |
+
img_name, img = images[rng.integers(0, len(images))]
|
| 327 |
+
deg_name = rng.choice(list(DEGRADATION_CONFIGS.keys()))
|
| 328 |
+
levels = DEGRADATION_CONFIGS[deg_name][0]
|
| 329 |
+
|
| 330 |
+
# Pick two different levels
|
| 331 |
+
l1, l2 = rng.choice(levels, size=2, replace=False)
|
| 332 |
+
if l1 == l2:
|
| 333 |
+
continue
|
| 334 |
+
|
| 335 |
+
degraded_1 = apply_degradation(img.copy(), deg_name, l1)
|
| 336 |
+
degraded_2 = apply_degradation(img.copy(), deg_name, l2)
|
| 337 |
+
|
| 338 |
+
pristine_feat = extractor.extract(img)
|
| 339 |
+
dist_1 = 1.0 - np.dot(pristine_feat, extractor.extract(degraded_1)) / (
|
| 340 |
+
np.linalg.norm(pristine_feat) * np.linalg.norm(extractor.extract(degraded_1)) + 1e-12
|
| 341 |
+
)
|
| 342 |
+
dist_2 = 1.0 - np.dot(pristine_feat, extractor.extract(degraded_2)) / (
|
| 343 |
+
np.linalg.norm(pristine_feat) * np.linalg.norm(extractor.extract(degraded_2)) + 1e-12
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
# More degraded (higher level) → should have larger distance
|
| 347 |
+
higher_level_is_1 = l1 > l2
|
| 348 |
+
higher_dist_is_1 = dist_1 > dist_2
|
| 349 |
+
|
| 350 |
+
if higher_level_is_1 == higher_dist_is_1:
|
| 351 |
+
correct += 1
|
| 352 |
+
per_deg[deg_name]["correct"] += 1
|
| 353 |
+
total += 1
|
| 354 |
+
per_deg[deg_name]["total"] += 1
|
| 355 |
+
|
| 356 |
+
accuracy = correct / total
|
| 357 |
+
print(f"\n Overall ranking accuracy: {accuracy:.4f} ({correct}/{total})")
|
| 358 |
+
print(f"\n {'Degradation':20s} {'Accuracy':>10s} {'N':>6s}")
|
| 359 |
+
print(f" {'-'*40}")
|
| 360 |
+
|
| 361 |
+
per_deg_results = []
|
| 362 |
+
for deg_name in sorted(per_deg.keys()):
|
| 363 |
+
d = per_deg[deg_name]
|
| 364 |
+
acc = d["correct"] / d["total"] if d["total"] > 0 else 0
|
| 365 |
+
per_deg_results.append({
|
| 366 |
+
"degradation": deg_name,
|
| 367 |
+
"accuracy": acc,
|
| 368 |
+
"n_pairs": d["total"],
|
| 369 |
+
})
|
| 370 |
+
print(f" {deg_name:20s} {acc:>10.4f} {d['total']:>6d}")
|
| 371 |
+
|
| 372 |
+
return {"overall_accuracy": accuracy, "per_degradation": per_deg_results}
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
# ===================================================================
|
| 376 |
+
# Benchmark 4: OCR-Quality dataset (optional, requires HF)
|
| 377 |
+
# ===================================================================
|
| 378 |
+
|
| 379 |
+
def benchmark_ocr_quality_dataset(
|
| 380 |
+
extractor: Layer12ONNXExtractor,
|
| 381 |
+
) -> List[Dict]:
|
| 382 |
+
"""
|
| 383 |
+
Evaluate on OCR-Quality dataset from HuggingFace.
|
| 384 |
+
Requires: pip install datasets huggingface_hub
|
| 385 |
+
"""
|
| 386 |
+
print("\n" + "=" * 60)
|
| 387 |
+
print("BENCHMARK 4: OCR-Quality Dataset")
|
| 388 |
+
print("=" * 60)
|
| 389 |
+
|
| 390 |
+
try:
|
| 391 |
+
from datasets import load_dataset
|
| 392 |
+
except ImportError:
|
| 393 |
+
print(" SKIPPED: 'datasets' package not installed.")
|
| 394 |
+
print(" Install: pip install datasets huggingface_hub")
|
| 395 |
+
return []
|
| 396 |
+
|
| 397 |
+
try:
|
| 398 |
+
ds = load_dataset("Aslan-mingye/OCR-Quality", split="train")
|
| 399 |
+
print(f" Loaded {len(ds)} samples")
|
| 400 |
+
except Exception as e:
|
| 401 |
+
print(f" SKIPPED: Could not load dataset: {e}")
|
| 402 |
+
return []
|
| 403 |
+
|
| 404 |
+
results = []
|
| 405 |
+
# TODO: full evaluation — extract features, correlate with human labels
|
| 406 |
+
print(" (Feature extraction + correlation with human quality labels...)")
|
| 407 |
+
|
| 408 |
+
return results
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
# ===================================================================
|
| 412 |
+
# Main
|
| 413 |
+
# ===================================================================
|
| 414 |
+
|
| 415 |
+
def main():
|
| 416 |
+
parser = argparse.ArgumentParser(
|
| 417 |
+
description="Benchmark PaddleOCR-VL Layer-12 features"
|
| 418 |
+
)
|
| 419 |
+
parser.add_argument("--all", action="store_true", help="Run all benchmarks")
|
| 420 |
+
parser.add_argument("--ocr-quality", action="store_true",
|
| 421 |
+
help="Include OCR-Quality dataset benchmark")
|
| 422 |
+
parser.add_argument("--model", type=str, default=None,
|
| 423 |
+
help="Path to ONNX model")
|
| 424 |
+
args = parser.parse_args()
|
| 425 |
+
|
| 426 |
+
model_path = args.model or os.path.join(
|
| 427 |
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
| 428 |
+
"model.onnx",
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
print("Loading ONNX model...")
|
| 432 |
+
t0 = time.time()
|
| 433 |
+
extractor = Layer12ONNXExtractor(model_path)
|
| 434 |
+
print(f" Loaded in {time.time()-t0:.1f}s")
|
| 435 |
+
print(f" Feature dim: {extractor.feature_dim}D")
|
| 436 |
+
print(f" Provider: {extractor.provider}")
|
| 437 |
+
|
| 438 |
+
all_results = {}
|
| 439 |
+
|
| 440 |
+
# Benchmark 1: Degradation sensitivity (always run)
|
| 441 |
+
t0 = time.time()
|
| 442 |
+
sens_results = benchmark_degradation_sensitivity(extractor, num_images=5)
|
| 443 |
+
all_results["degradation_sensitivity"] = sens_results
|
| 444 |
+
print(f"\n Completed in {time.time()-t0:.1f}s")
|
| 445 |
+
|
| 446 |
+
# Benchmark 2: Resolution consistency
|
| 447 |
+
t0 = time.time()
|
| 448 |
+
res_results = benchmark_resolution_consistency(extractor)
|
| 449 |
+
all_results["resolution_consistency"] = res_results
|
| 450 |
+
print(f"\n Completed in {time.time()-t0:.1f}s")
|
| 451 |
+
|
| 452 |
+
# Benchmark 3: Paired ranking
|
| 453 |
+
t0 = time.time()
|
| 454 |
+
rank_results = benchmark_paired_ranking(extractor, num_pairs=200)
|
| 455 |
+
all_results["paired_ranking"] = rank_results
|
| 456 |
+
print(f"\n Completed in {time.time()-t0:.1f}s")
|
| 457 |
+
|
| 458 |
+
# Benchmark 4: OCR-Quality (optional)
|
| 459 |
+
if args.all or args.ocr_quality:
|
| 460 |
+
ocr_results = benchmark_ocr_quality_dataset(extractor)
|
| 461 |
+
all_results["ocr_quality"] = ocr_results
|
| 462 |
+
|
| 463 |
+
# Save results
|
| 464 |
+
out_path = os.path.join(OUTPUT_DIR, "benchmark_results.json")
|
| 465 |
+
with open(out_path, "w") as f:
|
| 466 |
+
json.dump(all_results, f, indent=2, default=str)
|
| 467 |
+
print(f"\nResults saved to {out_path}")
|
| 468 |
+
|
| 469 |
+
# Summary
|
| 470 |
+
print("\n" + "=" * 60)
|
| 471 |
+
print("SUMMARY")
|
| 472 |
+
print("=" * 60)
|
| 473 |
+
print(f" Degradation sensitivity (mean |ρ|): "
|
| 474 |
+
f"{np.mean([abs(r['spearman_r']) for r in sens_results]):.4f}")
|
| 475 |
+
print(f" Paired ranking accuracy: {rank_results['overall_accuracy']:.4f}")
|
| 476 |
+
print(f" Resolution consistency: check {out_path}")
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
if __name__ == "__main__":
|
| 480 |
+
import cv2 # needed for some degradations
|
| 481 |
+
main()
|
inference/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# PaddleOCR-VL Quality Feature Extractor — ONNX Inference
|
inference/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (162 Bytes). View file
|
|
|
inference/__pycache__/onnx_inference.cpython-313.pyc
ADDED
|
Binary file (8.11 kB). View file
|
|
|
inference/__pycache__/preprocessing.cpython-313.pyc
ADDED
|
Binary file (6.72 kB). View file
|
|
|
inference/example.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Example: PaddleOCR-VL Layer-12 Feature Extraction with ONNX
|
| 4 |
+
============================================================
|
| 5 |
+
Demonstrates:
|
| 6 |
+
1. Loading the ONNX model
|
| 7 |
+
2. Extracting features from an image
|
| 8 |
+
3. Computing quality via distance from reference
|
| 9 |
+
4. CV quality metrics (complementary)
|
| 10 |
+
5. Feature sensitivity to degradation
|
| 11 |
+
|
| 12 |
+
Requirements:
|
| 13 |
+
pip install onnxruntime numpy Pillow opencv-python
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import sys, os
|
| 19 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 20 |
+
|
| 21 |
+
from inference.onnx_inference import Layer12ONNXExtractor
|
| 22 |
+
from inference.preprocessing import preprocess_for_onnx
|
| 23 |
+
|
| 24 |
+
import cv2
|
| 25 |
+
import numpy as np
|
| 26 |
+
from PIL import Image, ImageFilter
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
# 1. Load model
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
MODEL_PATH = os.path.join(
|
| 34 |
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
| 35 |
+
"model.onnx",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
print("Loading ONNX model...")
|
| 39 |
+
extractor = Layer12ONNXExtractor(MODEL_PATH)
|
| 40 |
+
print(f" Feature dimension: {extractor.feature_dim}D")
|
| 41 |
+
print(f" Provider: {extractor.provider}")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# 2. Feature extraction
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
# Create a simple test image
|
| 49 |
+
img = Image.new("RGB", (512, 512), color=(240, 240, 240))
|
| 50 |
+
# Draw some "text-like" lines
|
| 51 |
+
from PIL import ImageDraw
|
| 52 |
+
draw = ImageDraw.Draw(img)
|
| 53 |
+
for y in range(20, 500, 30):
|
| 54 |
+
draw.rectangle([30, y, 480, y + 4], fill=(30, 30, 30))
|
| 55 |
+
|
| 56 |
+
print("\nExtracting features...")
|
| 57 |
+
features = extractor.extract(img)
|
| 58 |
+
print(f" Shape: {features.shape}")
|
| 59 |
+
print(f" Mean: {features.mean():.4f}")
|
| 60 |
+
print(f" Std: {features.std():.4f}")
|
| 61 |
+
print(f" Min: {features.min():.4f}")
|
| 62 |
+
print(f" Max: {features.max():.4f}")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# 3. Quality via distance from reference
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
# Pristine reference (same image)
|
| 70 |
+
pristine = img.copy()
|
| 71 |
+
# Degraded version
|
| 72 |
+
blurred = img.filter(ImageFilter.GaussianBlur(radius=5))
|
| 73 |
+
|
| 74 |
+
dist = extractor.distance_from_reference(blurred, pristine)
|
| 75 |
+
quality = extractor.quality_score(blurred, reference=pristine)
|
| 76 |
+
|
| 77 |
+
print(f"\nQuality assessment:")
|
| 78 |
+
print(f" Blurred vs Pristine:")
|
| 79 |
+
print(f" Cosine distance: {dist:.6f}")
|
| 80 |
+
print(f" Quality score: {quality:.4f}")
|
| 81 |
+
|
| 82 |
+
# Self-comparison
|
| 83 |
+
self_dist = extractor.distance_from_reference(pristine, pristine)
|
| 84 |
+
self_quality = extractor.quality_score(pristine, reference=pristine)
|
| 85 |
+
print(f" Pristine vs Pristine:")
|
| 86 |
+
print(f" Cosine distance: {self_dist:.6f}")
|
| 87 |
+
print(f" Quality score: {self_quality:.4f}")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
# 4. Degradation sensitivity sweep
|
| 92 |
+
# ---------------------------------------------------------------------------
|
| 93 |
+
|
| 94 |
+
print("\nDegradation sensitivity (layer_12):")
|
| 95 |
+
print(f" {'Degradation':20s} {'Distance':>10s} {'Quality':>10s}")
|
| 96 |
+
print(f" {'-'*42}")
|
| 97 |
+
|
| 98 |
+
# Test different blur levels
|
| 99 |
+
for blur_r in [0, 1, 3, 5, 9, 13]:
|
| 100 |
+
degraded = img.filter(ImageFilter.GaussianBlur(radius=blur_r))
|
| 101 |
+
dist = extractor.distance_from_reference(degraded, pristine)
|
| 102 |
+
quality = extractor.quality_score(degraded, reference=pristine)
|
| 103 |
+
print(f" {'blur_'+str(blur_r):20s} {dist:>10.6f} {quality:>10.4f}")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
# 5. Complementary CV quality metrics
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
def cv_quality_metrics(pil_img: Image.Image) -> dict:
|
| 111 |
+
"""Fast traditional CV metrics (complement deep features)."""
|
| 112 |
+
gray = cv2.cvtColor(np.array(pil_img.convert("RGB")), cv2.COLOR_RGB2GRAY)
|
| 113 |
+
|
| 114 |
+
# Laplacian variance (blur detector)
|
| 115 |
+
lap = cv2.Laplacian(gray, cv2.CV_64F).var()
|
| 116 |
+
|
| 117 |
+
# Brightness deviation from ideal (128)
|
| 118 |
+
brightness_dev = abs(gray.mean() - 128) / 128
|
| 119 |
+
|
| 120 |
+
# Edge density
|
| 121 |
+
edges = cv2.Canny(gray, 50, 150)
|
| 122 |
+
edge_density = edges.sum() / edges.size
|
| 123 |
+
|
| 124 |
+
# High-frequency energy (FFT)
|
| 125 |
+
fft = np.fft.fft2(gray.astype(np.float32))
|
| 126 |
+
fft_shift = np.fft.fftshift(fft)
|
| 127 |
+
mag = np.abs(fft_shift)
|
| 128 |
+
h, w = mag.shape
|
| 129 |
+
ch, cw = h // 2, w // 2
|
| 130 |
+
r = min(h, w) // 4
|
| 131 |
+
y, x = np.ogrid[-ch:h-ch, -cw:w-cw]
|
| 132 |
+
high_freq_mask = (x*x + y*y) > (r*r)
|
| 133 |
+
hf_energy = mag[high_freq_mask].sum() / (mag.sum() + 1e-12)
|
| 134 |
+
|
| 135 |
+
# Contrast (IQR)
|
| 136 |
+
p25, p75 = np.percentile(gray, [25, 75])
|
| 137 |
+
contrast_iqr = (p75 - p25) / 255
|
| 138 |
+
|
| 139 |
+
return {
|
| 140 |
+
"laplacian_var": float(lap),
|
| 141 |
+
"brightness_dev": float(brightness_dev),
|
| 142 |
+
"edge_density": float(edge_density),
|
| 143 |
+
"high_freq_energy": float(hf_energy),
|
| 144 |
+
"contrast_iqr": float(contrast_iqr),
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
print("\nCV quality metrics:")
|
| 149 |
+
for label, img_obj in [("pristine", pristine), ("blurred_r5", blurred)]:
|
| 150 |
+
cv = cv_quality_metrics(img_obj)
|
| 151 |
+
print(f" {label}:")
|
| 152 |
+
for k, v in cv.items():
|
| 153 |
+
print(f" {k}: {v:.4f}")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ---------------------------------------------------------------------------
|
| 157 |
+
# 6. Preprocessing details
|
| 158 |
+
# ---------------------------------------------------------------------------
|
| 159 |
+
|
| 160 |
+
print("\nPreprocessing details:")
|
| 161 |
+
pixel_values, position_ids = preprocess_for_onnx(img)
|
| 162 |
+
print(f" pixel_values: shape={pixel_values.shape}, dtype={pixel_values.dtype}")
|
| 163 |
+
print(f" position_ids: shape={position_ids.shape}, dtype={position_ids.dtype}")
|
| 164 |
+
print(f" Num patches: {pixel_values.shape[1]}")
|
| 165 |
+
print(f" Patch size: 14×14×3")
|
| 166 |
+
print(f" Grid: sqrt({pixel_values.shape[1]}) ≈ {int(np.sqrt(pixel_values.shape[1]))}")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
print("\n✓ All examples completed successfully!")
|
inference/onnx_inference.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PaddleOCR-VL Layer-12 Feature Extractor — ONNX Inference
|
| 3 |
+
=========================================================
|
| 4 |
+
Pure ONNX Runtime inference with NO PyTorch dependency.
|
| 5 |
+
|
| 6 |
+
Extracts 1152-dimensional intermediate features from layer 12 of the
|
| 7 |
+
PaddleOCR-VL vision encoder. These features capture mid-level visual
|
| 8 |
+
patterns (texture, edge structure, compression artifacts) that are
|
| 9 |
+
highly predictive of image quality for OCR applications.
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
from inference.onnx_inference import Layer12ONNXExtractor
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
extractor = Layer12ONNXExtractor("paddleocr_layer12.onnx")
|
| 16 |
+
img = Image.open("document.jpg").convert("RGB")
|
| 17 |
+
features = extractor.extract(img) # → np.ndarray shape (1152,)
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
from typing import Dict, List, Optional, Tuple
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
from PIL import Image
|
| 27 |
+
|
| 28 |
+
from .preprocessing import preprocess_for_onnx
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class Layer12ONNXExtractor:
|
| 32 |
+
"""
|
| 33 |
+
ONNX-based feature extractor for PaddleOCR-VL layer 12.
|
| 34 |
+
|
| 35 |
+
Input: PIL image (any size, RGB)
|
| 36 |
+
Output: 1152D feature vector (mean-pooled across patches)
|
| 37 |
+
|
| 38 |
+
Model architecture:
|
| 39 |
+
PaddleOCR-VL vision encoder truncated after layer 12.
|
| 40 |
+
237M params → 0.8 MB ONNX (constant-folded).
|
| 41 |
+
|
| 42 |
+
Key properties:
|
| 43 |
+
- Dynamic input resolution: any image size → smart_resize →
|
| 44 |
+
variable num_patches
|
| 45 |
+
- Batch inference: pass batch of images
|
| 46 |
+
- No GPU required: CPU inference ~50-200ms/image
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
def __init__(
|
| 50 |
+
self,
|
| 51 |
+
model_path: str = "model.onnx",
|
| 52 |
+
providers: Optional[List[str]] = None,
|
| 53 |
+
):
|
| 54 |
+
"""
|
| 55 |
+
Args:
|
| 56 |
+
model_path: Path to ONNX model file.
|
| 57 |
+
providers: ONNX Runtime execution providers.
|
| 58 |
+
Default: ['CPUExecutionProvider']
|
| 59 |
+
For GPU: ['CUDAExecutionProvider', 'CPUExecutionProvider']
|
| 60 |
+
"""
|
| 61 |
+
import onnxruntime as ort
|
| 62 |
+
|
| 63 |
+
if providers is None:
|
| 64 |
+
# Auto-detect available providers
|
| 65 |
+
available = ort.get_available_providers()
|
| 66 |
+
if "CUDAExecutionProvider" in available:
|
| 67 |
+
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
|
| 68 |
+
else:
|
| 69 |
+
providers = ["CPUExecutionProvider"]
|
| 70 |
+
|
| 71 |
+
self._sess = ort.InferenceSession(model_path, providers=providers)
|
| 72 |
+
self._providers = providers
|
| 73 |
+
|
| 74 |
+
# Cache model metadata
|
| 75 |
+
self.input_names = [inp.name for inp in self._sess.get_inputs()]
|
| 76 |
+
self.output_names = [out.name for out in self._sess.get_outputs()]
|
| 77 |
+
|
| 78 |
+
# Feature dimension
|
| 79 |
+
output_shape = self._sess.get_outputs()[0].shape
|
| 80 |
+
self.feature_dim = output_shape[-1] # 1152
|
| 81 |
+
|
| 82 |
+
print(f"[Layer12ONNX] Loaded: {os.path.basename(model_path)}")
|
| 83 |
+
print(f"[Layer12ONNX] Providers: {self._sess.get_providers()}")
|
| 84 |
+
print(f"[Layer12ONNX] Feature dim: {self.feature_dim}")
|
| 85 |
+
print(f"[Layer12ONNX] Input: {list(zip(self.input_names, [i.shape for i in self._sess.get_inputs()]))}")
|
| 86 |
+
|
| 87 |
+
def extract(self, image: Image.Image) -> np.ndarray:
|
| 88 |
+
"""
|
| 89 |
+
Extract layer-12 features from a single image.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
image: PIL RGB image.
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
features: (1152,) float32 — mean-pooled layer-12 hidden states.
|
| 96 |
+
"""
|
| 97 |
+
pixel_values, position_ids = preprocess_for_onnx(image)
|
| 98 |
+
|
| 99 |
+
outputs = self._sess.run(
|
| 100 |
+
self.output_names,
|
| 101 |
+
{
|
| 102 |
+
"pixel_values": pixel_values,
|
| 103 |
+
"position_ids": position_ids,
|
| 104 |
+
},
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
return outputs[0].flatten().astype(np.float32)
|
| 108 |
+
|
| 109 |
+
def extract_batch(
|
| 110 |
+
self,
|
| 111 |
+
images: List[Image.Image],
|
| 112 |
+
) -> np.ndarray:
|
| 113 |
+
"""
|
| 114 |
+
Extract features from a batch of images.
|
| 115 |
+
|
| 116 |
+
Note: Since each image produces a different num_patches (dynamic),
|
| 117 |
+
batch inference processes images sequentially by default. For
|
| 118 |
+
same-size images, you can stack them manually.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
images: List of PIL images.
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
features: (batch_size, 1152) float32
|
| 125 |
+
"""
|
| 126 |
+
features = [self.extract(img) for img in images]
|
| 127 |
+
return np.stack(features, axis=0)
|
| 128 |
+
|
| 129 |
+
def distance_from_reference(
|
| 130 |
+
self,
|
| 131 |
+
image: Image.Image,
|
| 132 |
+
reference: Image.Image,
|
| 133 |
+
) -> float:
|
| 134 |
+
"""
|
| 135 |
+
Compute cosine distance from a pristine reference image.
|
| 136 |
+
|
| 137 |
+
Higher distance → more degraded. Range: [0, 2] typically.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
image: Test image.
|
| 141 |
+
reference: Pristine reference image.
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
cosine_distance: 1 - cosine_similarity(f_test, f_ref)
|
| 145 |
+
"""
|
| 146 |
+
f_test = self.extract(image)
|
| 147 |
+
f_ref = self.extract(reference)
|
| 148 |
+
|
| 149 |
+
cos_sim = np.dot(f_test, f_ref) / (
|
| 150 |
+
np.linalg.norm(f_test) * np.linalg.norm(f_ref) + 1e-12
|
| 151 |
+
)
|
| 152 |
+
return float(1.0 - cos_sim)
|
| 153 |
+
|
| 154 |
+
def quality_score(
|
| 155 |
+
self,
|
| 156 |
+
image: Image.Image,
|
| 157 |
+
reference: Optional[Image.Image] = None,
|
| 158 |
+
) -> float:
|
| 159 |
+
"""
|
| 160 |
+
Convert feature distance to a quality score in [0, 1].
|
| 161 |
+
|
| 162 |
+
If reference is provided: score = exp(-5 * cosine_distance)
|
| 163 |
+
If no reference: returns raw feature norm (heuristic).
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
image: Test image.
|
| 167 |
+
reference: Optional pristine reference.
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
score: 1.0 = pristine, →0 = heavily degraded.
|
| 171 |
+
"""
|
| 172 |
+
if reference is not None:
|
| 173 |
+
dist = self.distance_from_reference(image, reference)
|
| 174 |
+
return float(np.exp(-5.0 * dist))
|
| 175 |
+
|
| 176 |
+
# Heuristic: feature magnitude as quality proxy
|
| 177 |
+
features = self.extract(image)
|
| 178 |
+
norm = np.linalg.norm(features) / np.sqrt(self.feature_dim)
|
| 179 |
+
return float(np.clip(1.0 / (1.0 + np.exp(-5 * (0.5 - norm))), 0.0, 1.0))
|
| 180 |
+
|
| 181 |
+
@property
|
| 182 |
+
def provider(self) -> str:
|
| 183 |
+
"""Active execution provider."""
|
| 184 |
+
return self._sess.get_providers()[0]
|
inference/preprocessing.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Standalone PaddleOCR-VL Image Preprocessing (No PyTorch / No Transformers)
|
| 3 |
+
==========================================================================
|
| 4 |
+
Replicates the official PaddleOCRVLImageProcessor pipeline using only
|
| 5 |
+
Pillow + NumPy.
|
| 6 |
+
|
| 7 |
+
Pipeline:
|
| 8 |
+
1. Convert to RGB
|
| 9 |
+
2. smart_resize → both dims divisible by 28, within pixel budget
|
| 10 |
+
3. Rescale to [0, 1]
|
| 11 |
+
4. Normalize (CLIP mean/std)
|
| 12 |
+
5. Patchify → (num_patches, 3, 14, 14)
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import math
|
| 18 |
+
from typing import Tuple
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
from PIL import Image
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# Constants (from PaddleOCR-VL config)
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
PATCH_SIZE = 14
|
| 28 |
+
MERGE_SIZE = 2
|
| 29 |
+
FACTOR = PATCH_SIZE * MERGE_SIZE # 28
|
| 30 |
+
|
| 31 |
+
MIN_PIXELS = 28 * 28 * 130 # 101,920
|
| 32 |
+
MAX_PIXELS = 28 * 28 * 1280 # 1,003,520
|
| 33 |
+
|
| 34 |
+
# CLIP mean / std (OpenAI variant used by PaddleOCR-VL)
|
| 35 |
+
IMAGE_MEAN = np.array([0.48145466, 0.4578275, 0.40821073], dtype=np.float32)
|
| 36 |
+
IMAGE_STD = np.array([0.26862954, 0.26130258, 0.27577711], dtype=np.float32)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# smart_resize
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
def smart_resize(
|
| 44 |
+
height: int,
|
| 45 |
+
width: int,
|
| 46 |
+
factor: int = FACTOR,
|
| 47 |
+
min_pixels: int = MIN_PIXELS,
|
| 48 |
+
max_pixels: int = MAX_PIXELS,
|
| 49 |
+
) -> Tuple[int, int]:
|
| 50 |
+
"""
|
| 51 |
+
Rescale so that:
|
| 52 |
+
1. Both dimensions are divisible by `factor`.
|
| 53 |
+
2. Total pixels ∈ [min_pixels, max_pixels].
|
| 54 |
+
3. Aspect ratio is preserved as closely as possible.
|
| 55 |
+
"""
|
| 56 |
+
if height < factor:
|
| 57 |
+
width = round((width * factor) / height)
|
| 58 |
+
height = factor
|
| 59 |
+
if width < factor:
|
| 60 |
+
height = round((height * factor) / width)
|
| 61 |
+
width = factor
|
| 62 |
+
|
| 63 |
+
if max(height, width) / min(height, width) > 200:
|
| 64 |
+
raise ValueError(
|
| 65 |
+
f"Aspect ratio too extreme: {max(height, width) / min(height, width)}"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
h_bar = round(height / factor) * factor
|
| 69 |
+
w_bar = round(width / factor) * factor
|
| 70 |
+
|
| 71 |
+
if h_bar * w_bar > max_pixels:
|
| 72 |
+
beta = math.sqrt((height * width) / max_pixels)
|
| 73 |
+
h_bar = math.floor(height / beta / factor) * factor
|
| 74 |
+
w_bar = math.floor(width / beta / factor) * factor
|
| 75 |
+
elif h_bar * w_bar < min_pixels:
|
| 76 |
+
beta = math.sqrt(min_pixels / (height * width))
|
| 77 |
+
h_bar = math.ceil(height * beta / factor) * factor
|
| 78 |
+
w_bar = math.ceil(width * beta / factor) * factor
|
| 79 |
+
|
| 80 |
+
return h_bar, w_bar
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# Preprocessing
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
def preprocess(
|
| 88 |
+
image: Image.Image,
|
| 89 |
+
) -> Tuple[np.ndarray, Tuple[int, int, int]]:
|
| 90 |
+
"""
|
| 91 |
+
Preprocess a PIL image into patch tensor for ONNX inference.
|
| 92 |
+
|
| 93 |
+
Args:
|
| 94 |
+
image: PIL RGB image (any size).
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
pixel_values: (num_patches, 3, 14, 14) float32 array
|
| 98 |
+
grid_thw: (grid_t, grid_h, grid_w) — temporal=1 always
|
| 99 |
+
"""
|
| 100 |
+
# 1. Convert to RGB
|
| 101 |
+
img = image.convert("RGB")
|
| 102 |
+
|
| 103 |
+
# 2. smart_resize
|
| 104 |
+
width, height = img.size
|
| 105 |
+
new_h, new_w = smart_resize(height, width)
|
| 106 |
+
|
| 107 |
+
if (new_w, new_h) != (width, height):
|
| 108 |
+
img = img.resize((new_w, new_h), Image.BICUBIC)
|
| 109 |
+
|
| 110 |
+
# 3. To numpy, rescale to [0, 1]
|
| 111 |
+
arr = np.array(img, dtype=np.float32) / 255.0
|
| 112 |
+
|
| 113 |
+
# 4. Normalize (CHW)
|
| 114 |
+
arr = (arr - IMAGE_MEAN.reshape(1, 1, 3)) / IMAGE_STD.reshape(1, 1, 3)
|
| 115 |
+
arr = arr.transpose(2, 0, 1) # HWC → CHW
|
| 116 |
+
|
| 117 |
+
# 5. Patchify: (C, H, W) → (num_patches, C, 14, 14)
|
| 118 |
+
c, h, w = arr.shape
|
| 119 |
+
grid_h = h // PATCH_SIZE
|
| 120 |
+
grid_w = w // PATCH_SIZE
|
| 121 |
+
grid_t = 1 # temporal patches = 1 for images
|
| 122 |
+
|
| 123 |
+
# Reshape into grid of patches
|
| 124 |
+
patches = arr.reshape(c, grid_h, PATCH_SIZE, grid_w, PATCH_SIZE)
|
| 125 |
+
patches = patches.transpose(1, 3, 0, 2, 4) # → (grid_h, grid_w, c, 14, 14)
|
| 126 |
+
patches = patches.reshape(-1, c, PATCH_SIZE, PATCH_SIZE) # → (N, 3, 14, 14)
|
| 127 |
+
|
| 128 |
+
# Handle temporal dim (always 1 for images)
|
| 129 |
+
patches = np.tile(patches, (grid_t, 1, 1, 1))
|
| 130 |
+
|
| 131 |
+
grid_thw = (grid_t, grid_h, grid_w)
|
| 132 |
+
|
| 133 |
+
return patches.astype(np.float32), grid_thw
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def preprocess_for_onnx(image: Image.Image) -> Tuple[np.ndarray, np.ndarray]:
|
| 137 |
+
"""
|
| 138 |
+
Preprocess image and return ONNX-ready inputs.
|
| 139 |
+
|
| 140 |
+
Args:
|
| 141 |
+
image: PIL RGB image.
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
pixel_values: (1, num_patches, 3, 14, 14) float32
|
| 145 |
+
position_ids: (1, 1) int64
|
| 146 |
+
"""
|
| 147 |
+
patches, _grid_thw = preprocess(image)
|
| 148 |
+
# Add batch dimension
|
| 149 |
+
pixel_values = patches[np.newaxis, ...] # (1, N, 3, 14, 14)
|
| 150 |
+
position_ids = np.zeros((1, 1), dtype=np.int64)
|
| 151 |
+
return pixel_values.astype(np.float32), position_ids
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ---------------------------------------------------------------------------
|
| 155 |
+
# Reverse: patches → image (for verification)
|
| 156 |
+
# ---------------------------------------------------------------------------
|
| 157 |
+
|
| 158 |
+
def patches_to_image(
|
| 159 |
+
pixel_values: np.ndarray,
|
| 160 |
+
grid_h: int,
|
| 161 |
+
grid_w: int,
|
| 162 |
+
) -> Image.Image:
|
| 163 |
+
"""
|
| 164 |
+
Reconstruct an image from patch tensor (for debugging).
|
| 165 |
+
|
| 166 |
+
Args:
|
| 167 |
+
pixel_values: (N, 3, 14, 14) or (1, N, 3, 14, 14)
|
| 168 |
+
grid_h, grid_w: grid dimensions
|
| 169 |
+
|
| 170 |
+
Returns:
|
| 171 |
+
PIL Image (approx reconstruction of preprocessed input)
|
| 172 |
+
"""
|
| 173 |
+
if pixel_values.ndim == 5:
|
| 174 |
+
pixel_values = pixel_values.squeeze(0) # remove batch
|
| 175 |
+
|
| 176 |
+
n_patches = grid_h * grid_w
|
| 177 |
+
patches = pixel_values[:n_patches] # (N, 3, 14, 14)
|
| 178 |
+
|
| 179 |
+
# Un-patchify
|
| 180 |
+
c = patches.shape[1]
|
| 181 |
+
ps = patches.shape[2]
|
| 182 |
+
patches = patches.reshape(grid_h, grid_w, c, ps, ps)
|
| 183 |
+
patches = patches.transpose(2, 0, 3, 1, 4) # → (c, grid_h, ps, grid_w, ps)
|
| 184 |
+
img = patches.reshape(c, grid_h * ps, grid_w * ps) # (c, H, W)
|
| 185 |
+
|
| 186 |
+
# De-normalize
|
| 187 |
+
img = img.transpose(1, 2, 0) # CHW → HWC
|
| 188 |
+
img = img * IMAGE_STD.reshape(1, 1, 3) + IMAGE_MEAN.reshape(1, 1, 3)
|
| 189 |
+
img = np.clip(img * 255, 0, 255).astype(np.uint8)
|
| 190 |
+
|
| 191 |
+
return Image.fromarray(img)
|
model.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1f16a79721888d293eac3c2480ee9c95bcbe59e536a5854edfac0828090e07a7
|
| 3 |
+
size 871673
|
model.onnx_data
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c2e182eb637e03160849ecf345f34b2cb8ad82913593354f6a4f4720cf159e6d
|
| 3 |
+
size 946163264
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
onnxruntime>=1.18.0
|
| 2 |
+
numpy>=1.24.0
|
| 3 |
+
Pillow>=10.0.0
|
| 4 |
+
opencv-python>=4.8.0
|