Feature Extraction
sentence-transformers
Safetensors
Transformers
nanovdr_doc
visual-document-retrieval
document-ai
nanovdr
knowledge-distillation
retrieval
custom_code
Instructions to use nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- sentence-transformers
How to use nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096 with sentence-transformers:
from sentence_transformers import SentenceTransformer model = SentenceTransformer("nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096", trust_remote_code=True) sentences = [ "The weather is lovely today.", "It's so sunny outside!", "He drove to the stadium." ] embeddings = model.encode(sentences) similarities = model.similarity(embeddings, embeddings) print(similarities.shape) # [3, 3] - Transformers
How to use nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096 with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("feature-extraction", model="nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
NanoVDR-D-HiRes: 457M document tower distilled from Qwen3-VL-Embedding-8B
Browse files- README.md +189 -0
- config.json +24 -0
- model.safetensors +3 -0
- modeling_nanovdr_doc.py +291 -0
- preprocessor_config.json +27 -0
README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
library_name: transformers
|
| 4 |
+
pipeline_tag: feature-extraction
|
| 5 |
+
tags:
|
| 6 |
+
- visual-document-retrieval
|
| 7 |
+
- document-ai
|
| 8 |
+
- nanovdr
|
| 9 |
+
- knowledge-distillation
|
| 10 |
+
- retrieval
|
| 11 |
+
base_model:
|
| 12 |
+
- OpenGVLab/InternViT-300M-448px-V2_5
|
| 13 |
+
- answerdotai/ModernBERT-base
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
<p align="center">
|
| 17 |
+
<img width="440" src="https://huggingface.co/nanovdr/NanoVDR-S-Multi/resolve/main/banner.png" alt="NanoVDR"/>
|
| 18 |
+
</p>
|
| 19 |
+
|
| 20 |
+
<h3 align="center">NanoVDR-D-HiRes — the document tower</h3>
|
| 21 |
+
|
| 22 |
+
<p align="center">
|
| 23 |
+
<a href="https://huggingface.co/nanovdr">Models</a> |
|
| 24 |
+
<a href="https://github.com/Ryenhails/NanoVDR">Code</a> |
|
| 25 |
+
<a href="https://huggingface.co/spaces/nanovdr/NanoVDR-Demo">Demo</a> |
|
| 26 |
+
<a href="#citation">Paper</a>
|
| 27 |
+
</p>
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
A **457M** encoder that turns a document page image into **one 4096-d vector**.
|
| 32 |
+
It is distilled from a frozen 8B vision-language teacher
|
| 33 |
+
([Qwen3-VL-Embedding-8B](https://huggingface.co/Qwen/Qwen3-VL-Embedding-8B))
|
| 34 |
+
by direct representation alignment: the student is trained only to reproduce
|
| 35 |
+
the teacher's page embedding, with no relevance labels, no negative mining and
|
| 36 |
+
no contrastive term.
|
| 37 |
+
|
| 38 |
+
Every other NanoVDR release replaces the *query* side and still needs the
|
| 39 |
+
teacher to build the index. This one replaces the *document* side, so paired
|
| 40 |
+
with a NanoVDR query tower the teacher is not needed at all — at indexing time
|
| 41 |
+
or at query time.
|
| 42 |
+
|
| 43 |
+
| | |
|
| 44 |
+
|---|---|
|
| 45 |
+
| Input | page image (PIL / any resolution) |
|
| 46 |
+
| Output | one L2-normalised 4096-d vector |
|
| 47 |
+
| Scoring | dot product |
|
| 48 |
+
| Index footprint | 16.4 GB per million pages (float32) |
|
| 49 |
+
| Teacher | Qwen3-VL-Embedding-8B, frozen |
|
| 50 |
+
| Visual tokens | up to 6 tiles at 448x448 + one whole-page thumbnail |
|
| 51 |
+
|
| 52 |
+
A `-Fast` variant with a 2-tile budget trades v3 quality for 3x fewer visual
|
| 53 |
+
tokens; see the [model index](https://huggingface.co/nanovdr).
|
| 54 |
+
|
| 55 |
+
## How it works
|
| 56 |
+
|
| 57 |
+
```
|
| 58 |
+
page ──► dynamic tiling ──► InternViT-300M-448 ──► linear ──► ModernBERT-base ──► mean pool ──► Linear(768→4096) ──► L2-norm
|
| 59 |
+
(≤6 tiles + per-tile patch 1024→768 bidirectional,
|
| 60 |
+
thumbnail) tokens, concatenated no text tokens
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
The tiling rule matches the page's aspect ratio to a grid at the encoder's
|
| 64 |
+
native 448px resolution and appends a thumbnail for global context, so small
|
| 65 |
+
text and table cells survive without the patch sequence outrunning
|
| 66 |
+
ModernBERT's 8192-token window.
|
| 67 |
+
|
| 68 |
+
## Usage
|
| 69 |
+
|
| 70 |
+
```python
|
| 71 |
+
import torch
|
| 72 |
+
from PIL import Image
|
| 73 |
+
from transformers import AutoModel, AutoImageProcessor
|
| 74 |
+
|
| 75 |
+
model = AutoModel.from_pretrained("nanovdr/NanoVDR-D-HiRes", trust_remote_code=True).eval()
|
| 76 |
+
processor = AutoImageProcessor.from_pretrained("nanovdr/NanoVDR-D-HiRes", trust_remote_code=True)
|
| 77 |
+
|
| 78 |
+
pages = [Image.open("page1.png"), Image.open("page2.png")]
|
| 79 |
+
doc_emb = model.encode(pages, processor, batch_size=4) # (2, 4096), L2-normalised
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
Pair it with a query tower and retrieval is a dot product:
|
| 83 |
+
|
| 84 |
+
```python
|
| 85 |
+
scores = query_emb @ doc_emb.T # (n_queries, n_pages)
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
On Ampere or newer, switch the fused kernels on:
|
| 89 |
+
|
| 90 |
+
```python
|
| 91 |
+
model = AutoModel.from_pretrained(
|
| 92 |
+
"nanovdr/NanoVDR-D-HiRes",
|
| 93 |
+
trust_remote_code=True,
|
| 94 |
+
use_flash_attn=True, # InternViT
|
| 95 |
+
text_attn_implementation="flash_attention_2",
|
| 96 |
+
).to("cuda", torch.bfloat16)
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
Weights ship in float32 so the model loads on any device; cast to bfloat16 for
|
| 100 |
+
deployment.
|
| 101 |
+
|
| 102 |
+
## Results
|
| 103 |
+
|
| 104 |
+
NDCG@5 on the full 22-dataset ViDoRe suite.
|
| 105 |
+
|
| 106 |
+
**End to end**, paired with the 70M text-only query tower distilled from the
|
| 107 |
+
same teacher — the teacher runs nowhere at deployment:
|
| 108 |
+
|
| 109 |
+
| | v1 | v2 | v3 | Avg |
|
| 110 |
+
|---|---|---|---|---|
|
| 111 |
+
| NanoVDR-D-HiRes + 70M query tower | 82.81 | 55.34 | 47.07 | **61.74** |
|
| 112 |
+
| Qwen3-VL-Embedding-8B (teacher) | 87.31 | 69.76 | 56.07 | 71.05 |
|
| 113 |
+
| | | | | **86.9% of the teacher** |
|
| 114 |
+
|
| 115 |
+
**Document side in isolation**, scored against teacher-encoded queries, which
|
| 116 |
+
isolates this model from query-side error:
|
| 117 |
+
|
| 118 |
+
| | v1 | v2 | v3 | Avg |
|
| 119 |
+
|---|---|---|---|---|
|
| 120 |
+
| NanoVDR-D-HiRes | 83.72 | 60.92 | 50.43 | 65.02 |
|
| 121 |
+
|
| 122 |
+
At the sub-1B scale this beats every retriever we reproduced, including the
|
| 123 |
+
multi-vector ones, while storing 15.6x less index than the strongest of them.
|
| 124 |
+
|
| 125 |
+
### Deployment cost
|
| 126 |
+
|
| 127 |
+
Single H200, batch size 8, bfloat16:
|
| 128 |
+
|
| 129 |
+
| | |
|
| 130 |
+
|---|---|
|
| 131 |
+
| Document throughput | 36.82 pages/s |
|
| 132 |
+
| Peak VRAM | 3.07 GB |
|
| 133 |
+
| Index | 16.4 GB per million pages |
|
| 134 |
+
| Scoring 10K candidates | 9.6 ms (single CPU thread) |
|
| 135 |
+
|
| 136 |
+
## Training
|
| 137 |
+
|
| 138 |
+
| | |
|
| 139 |
+
|---|---|
|
| 140 |
+
| Objective | `1 - cos(student, teacher)` — nothing else |
|
| 141 |
+
| Data | 1.20M unique page images, public and permissively licensed |
|
| 142 |
+
| Dedup | perceptual hash against all three ViDoRe evaluation corpora |
|
| 143 |
+
| Optimizer | AdamW, one-cycle, peak LR 1e-3, 3% warmup |
|
| 144 |
+
| Effective batch | 256 |
|
| 145 |
+
| Epochs | 3 |
|
| 146 |
+
| Hardware | 2x H200 |
|
| 147 |
+
|
| 148 |
+
Teacher targets are cached once before training, so the document and query
|
| 149 |
+
towers train independently and in parallel.
|
| 150 |
+
|
| 151 |
+
## Limitations
|
| 152 |
+
|
| 153 |
+
- **Bounded by the teacher.** The student only reproduces
|
| 154 |
+
Qwen3-VL-Embedding-8B's embedding space; any systematic weakness of that
|
| 155 |
+
teacher carries over, and nothing in the objective lets the student exceed it.
|
| 156 |
+
- **Fixed tile budget.** Tiling adapts to page aspect ratio but not to page
|
| 157 |
+
content, so a dense small-text page and a single-figure page get the same
|
| 158 |
+
visual-token budget.
|
| 159 |
+
- **Evaluation is ViDoRe-only.** Three difficulty levels, six languages, eight
|
| 160 |
+
professional domains — but not in-house enterprise layouts, scanned or
|
| 161 |
+
OCR-degraded pages, or production query distributions.
|
| 162 |
+
- **Uncompressed index.** One 4096-d float32 vector per page. Quantisation and
|
| 163 |
+
product quantisation are untested here, so 16.4 GB per million is an upper
|
| 164 |
+
bound.
|
| 165 |
+
|
| 166 |
+
## Citation
|
| 167 |
+
|
| 168 |
+
A preprint describing this model and the full ablation study is on arXiv.
|
| 169 |
+
**The arXiv identifier is being added here shortly**; until then please cite:
|
| 170 |
+
|
| 171 |
+
```bibtex
|
| 172 |
+
@misc{nanovdr-doc-2026,
|
| 173 |
+
title = {Toward Efficient Representation Alignment for the Visual Tower
|
| 174 |
+
in Visual Document Retrieval},
|
| 175 |
+
author = {Liu, Zhuchenyang and Wang, Ziyi and Zhang, Yao and Xiao, Yu},
|
| 176 |
+
year = {2026},
|
| 177 |
+
howpublished = {\url{https://huggingface.co/nanovdr/NanoVDR-D-HiRes}},
|
| 178 |
+
note = {arXiv preprint, identifier to be assigned}
|
| 179 |
+
}
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
The companion write-up covering motivation, architecture and every ablation
|
| 183 |
+
behind this model is [here](https://huggingface.co/spaces/nanovdr/NanoVDR-D-Report).
|
| 184 |
+
|
| 185 |
+
## License
|
| 186 |
+
|
| 187 |
+
MIT. The visual and text backbones follow the licences of
|
| 188 |
+
[InternViT-300M-448px-V2_5](https://huggingface.co/OpenGVLab/InternViT-300M-448px-V2_5)
|
| 189 |
+
and [ModernBERT-base](https://huggingface.co/answerdotai/ModernBERT-base).
|
config.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architectures": [
|
| 3 |
+
"NanoVDRDocModel"
|
| 4 |
+
],
|
| 5 |
+
"auto_map": {
|
| 6 |
+
"AutoConfig": "modeling_nanovdr_doc.NanoVDRDocConfig",
|
| 7 |
+
"AutoModel": "modeling_nanovdr_doc.NanoVDRDocModel"
|
| 8 |
+
},
|
| 9 |
+
"dtype": "float32",
|
| 10 |
+
"embed_dim": 4096,
|
| 11 |
+
"image_size": 448,
|
| 12 |
+
"model_type": "nanovdr_doc",
|
| 13 |
+
"pixel_shuffle_r": 1,
|
| 14 |
+
"pool_type": "mean",
|
| 15 |
+
"text_attn_implementation": "sdpa",
|
| 16 |
+
"text_backbone_name": "answerdotai/ModernBERT-base",
|
| 17 |
+
"tile_max_num": 6,
|
| 18 |
+
"tile_max_total": 7,
|
| 19 |
+
"tile_min_num": 1,
|
| 20 |
+
"tile_use_thumbnail": true,
|
| 21 |
+
"transformers_version": "4.57.6",
|
| 22 |
+
"use_flash_attn": false,
|
| 23 |
+
"visual_encoder_name": "OpenGVLab/InternViT-300M-448px-V2_5"
|
| 24 |
+
}
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4cffc062320b7cdb609937283c17dff15d8d302c04458fe810acb7b4a3276f6d
|
| 3 |
+
size 1827909656
|
modeling_nanovdr_doc.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""NanoVDR document tower — standalone modeling file for the Hub.
|
| 2 |
+
|
| 3 |
+
A page image is tiled, every tile is encoded by a vision transformer, the patch
|
| 4 |
+
tokens of all tiles are concatenated into one sequence, a bidirectional text
|
| 5 |
+
backbone re-encodes that sequence (no text tokens involved), and a mean pool
|
| 6 |
+
plus linear projection produces one L2-normalised vector in the teacher's
|
| 7 |
+
embedding space.
|
| 8 |
+
|
| 9 |
+
Retrieval is a dot product against a query vector from the matching NanoVDR
|
| 10 |
+
query tower, or against the teacher itself.
|
| 11 |
+
|
| 12 |
+
This file is self-contained: it is the only source of truth for the forward
|
| 13 |
+
pass and does not import from the training repository.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import math
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
+
from typing import List, Optional, Sequence, Union
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
import torch.nn as nn
|
| 24 |
+
import torch.nn.functional as F
|
| 25 |
+
from transformers import AutoConfig, AutoModel, PretrainedConfig, PreTrainedModel
|
| 26 |
+
from transformers.modeling_outputs import ModelOutput
|
| 27 |
+
|
| 28 |
+
IMAGE_SIZE = 448
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class NanoVDRDocConfig(PretrainedConfig):
|
| 32 |
+
model_type = "nanovdr_doc"
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
visual_encoder_name: str = "OpenGVLab/InternViT-300M-448px-V2_5",
|
| 37 |
+
text_backbone_name: str = "answerdotai/ModernBERT-base",
|
| 38 |
+
embed_dim: int = 4096,
|
| 39 |
+
pool_type: str = "mean",
|
| 40 |
+
pixel_shuffle_r: int = 1,
|
| 41 |
+
tile_min_num: int = 1,
|
| 42 |
+
tile_max_num: int = 6,
|
| 43 |
+
tile_max_total: int = 7,
|
| 44 |
+
tile_use_thumbnail: bool = True,
|
| 45 |
+
image_size: int = IMAGE_SIZE,
|
| 46 |
+
use_flash_attn: bool = False,
|
| 47 |
+
text_attn_implementation: str = "sdpa",
|
| 48 |
+
**kwargs,
|
| 49 |
+
):
|
| 50 |
+
self.visual_encoder_name = visual_encoder_name
|
| 51 |
+
self.text_backbone_name = text_backbone_name
|
| 52 |
+
self.embed_dim = embed_dim
|
| 53 |
+
self.pool_type = pool_type
|
| 54 |
+
self.pixel_shuffle_r = pixel_shuffle_r
|
| 55 |
+
self.tile_min_num = tile_min_num
|
| 56 |
+
self.tile_max_num = tile_max_num
|
| 57 |
+
self.tile_max_total = tile_max_total
|
| 58 |
+
self.tile_use_thumbnail = tile_use_thumbnail
|
| 59 |
+
self.image_size = image_size
|
| 60 |
+
# InternViT ships a native flash-attention path and ModernBERT picks
|
| 61 |
+
# flash-attention-2 when the package is importable. Both need Ampere or
|
| 62 |
+
# newer, so the defaults here stay portable: plain attention for the
|
| 63 |
+
# vision tower and sdpa for the text tower (sdpa dispatches to a fused
|
| 64 |
+
# kernel on capable hardware anyway). Set use_flash_attn=True and
|
| 65 |
+
# text_attn_implementation="flash_attention_2" on Ampere+ for speed.
|
| 66 |
+
self.use_flash_attn = use_flash_attn
|
| 67 |
+
self.text_attn_implementation = text_attn_implementation
|
| 68 |
+
super().__init__(**kwargs)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# --------------------------------------------------------------------------
|
| 72 |
+
# Dynamic tiling (InternVL-V2 partition rule)
|
| 73 |
+
# --------------------------------------------------------------------------
|
| 74 |
+
def _closest_aspect_ratio(aspect_ratio, target_ratios, width, height, image_size):
|
| 75 |
+
best_diff, best = float("inf"), (1, 1)
|
| 76 |
+
area = width * height
|
| 77 |
+
for ratio in target_ratios:
|
| 78 |
+
target = ratio[0] / ratio[1]
|
| 79 |
+
diff = abs(aspect_ratio - target)
|
| 80 |
+
if diff < best_diff:
|
| 81 |
+
best_diff, best = diff, ratio
|
| 82 |
+
elif diff == best_diff:
|
| 83 |
+
if area > 0.5 * image_size * image_size * ratio[0] * ratio[1]:
|
| 84 |
+
best = ratio
|
| 85 |
+
return best
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def dynamic_tile(
|
| 89 |
+
image,
|
| 90 |
+
min_num: int = 1,
|
| 91 |
+
max_num: int = 6,
|
| 92 |
+
image_size: int = IMAGE_SIZE,
|
| 93 |
+
use_thumbnail: bool = True,
|
| 94 |
+
) -> list:
|
| 95 |
+
"""Split a page into aspect-ratio-matched ``image_size`` crops.
|
| 96 |
+
|
| 97 |
+
The final element is a whole-page thumbnail when ``use_thumbnail`` is set
|
| 98 |
+
and more than one crop was produced. Returns a list of PIL images.
|
| 99 |
+
"""
|
| 100 |
+
orig_w, orig_h = image.size
|
| 101 |
+
aspect_ratio = orig_w / orig_h
|
| 102 |
+
target_ratios = sorted(
|
| 103 |
+
{
|
| 104 |
+
(i, j)
|
| 105 |
+
for n in range(min_num, max_num + 1)
|
| 106 |
+
for i in range(1, n + 1)
|
| 107 |
+
for j in range(1, n + 1)
|
| 108 |
+
if i * j <= max_num and i * j >= min_num
|
| 109 |
+
},
|
| 110 |
+
key=lambda x: x[0] * x[1],
|
| 111 |
+
)
|
| 112 |
+
cols, rows = _closest_aspect_ratio(aspect_ratio, target_ratios, orig_w, orig_h, image_size)
|
| 113 |
+
resized = image.resize((image_size * cols, image_size * rows))
|
| 114 |
+
tiles = []
|
| 115 |
+
for i in range(cols * rows):
|
| 116 |
+
c, r = i % cols, i // cols
|
| 117 |
+
box = (c * image_size, r * image_size, (c + 1) * image_size, (r + 1) * image_size)
|
| 118 |
+
tiles.append(resized.crop(box))
|
| 119 |
+
if use_thumbnail and len(tiles) > 1:
|
| 120 |
+
tiles.append(image.resize((image_size, image_size)))
|
| 121 |
+
return tiles
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@dataclass
|
| 125 |
+
class NanoVDRDocOutput(ModelOutput):
|
| 126 |
+
embedding: Optional[torch.FloatTensor] = None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# --------------------------------------------------------------------------
|
| 130 |
+
# Model
|
| 131 |
+
# --------------------------------------------------------------------------
|
| 132 |
+
class NanoVDRDocModel(PreTrainedModel):
|
| 133 |
+
config_class = NanoVDRDocConfig
|
| 134 |
+
base_model_prefix = "nanovdr_doc"
|
| 135 |
+
_no_split_modules = ["InternVisionEncoderLayer", "ModernBertEncoderLayer"]
|
| 136 |
+
|
| 137 |
+
def __init__(self, config: NanoVDRDocConfig):
|
| 138 |
+
super().__init__(config)
|
| 139 |
+
self.cfg = config
|
| 140 |
+
|
| 141 |
+
vis_cfg = AutoConfig.from_pretrained(config.visual_encoder_name, trust_remote_code=True)
|
| 142 |
+
if hasattr(vis_cfg, "use_flash_attn"):
|
| 143 |
+
vis_cfg.use_flash_attn = config.use_flash_attn
|
| 144 |
+
visual = AutoModel.from_config(vis_cfg, trust_remote_code=True)
|
| 145 |
+
# Some releases wrap the tower; take the vision half when they do.
|
| 146 |
+
self.visual_encoder = getattr(visual, "vision_model", visual)
|
| 147 |
+
self.vis_dim = getattr(vis_cfg, "hidden_size", None) or vis_cfg.vision_config.hidden_size
|
| 148 |
+
|
| 149 |
+
txt_cfg = AutoConfig.from_pretrained(config.text_backbone_name)
|
| 150 |
+
if hasattr(txt_cfg, "reference_compile"):
|
| 151 |
+
txt_cfg.reference_compile = False
|
| 152 |
+
self.text_backbone = AutoModel.from_config(
|
| 153 |
+
txt_cfg, attn_implementation=config.text_attn_implementation
|
| 154 |
+
)
|
| 155 |
+
text_dim = txt_cfg.hidden_size
|
| 156 |
+
|
| 157 |
+
r = int(config.pixel_shuffle_r)
|
| 158 |
+
self.connect = nn.Linear(self.vis_dim * r * r, text_dim)
|
| 159 |
+
self.proj = nn.Linear(text_dim, config.embed_dim)
|
| 160 |
+
if config.pool_type == "eos":
|
| 161 |
+
self.eos_token = nn.Parameter(torch.randn(1, 1, text_dim) * 0.02)
|
| 162 |
+
|
| 163 |
+
self.post_init()
|
| 164 |
+
|
| 165 |
+
# -- internals ---------------------------------------------------------
|
| 166 |
+
def _pixel_shuffle(self, patches: torch.Tensor) -> torch.Tensor:
|
| 167 |
+
B, N, D = patches.shape
|
| 168 |
+
hw = int(round(math.sqrt(N)))
|
| 169 |
+
r = int(self.cfg.pixel_shuffle_r)
|
| 170 |
+
if hw * hw != N or hw % r != 0:
|
| 171 |
+
return patches
|
| 172 |
+
x = patches.reshape(B, hw, hw, D).reshape(B, hw // r, r, hw // r, r, D)
|
| 173 |
+
x = x.permute(0, 1, 3, 2, 4, 5).contiguous()
|
| 174 |
+
return x.reshape(B, (hw // r) ** 2, r * r * D)
|
| 175 |
+
|
| 176 |
+
# -- forward -----------------------------------------------------------
|
| 177 |
+
def forward(
|
| 178 |
+
self,
|
| 179 |
+
pixel_values: torch.Tensor,
|
| 180 |
+
tile_mask: Optional[torch.Tensor] = None,
|
| 181 |
+
return_dict: bool = True,
|
| 182 |
+
**kwargs,
|
| 183 |
+
):
|
| 184 |
+
"""Encode pages into L2-normalised embeddings.
|
| 185 |
+
|
| 186 |
+
Parameters
|
| 187 |
+
----------
|
| 188 |
+
pixel_values : (B, T, 3, H, W) or (B, 3, H, W)
|
| 189 |
+
Tiled pages, zero-padded to ``T`` tiles, or a single view per page.
|
| 190 |
+
tile_mask : (B, T) bool
|
| 191 |
+
True for real tiles. Required when ``pixel_values`` is 5-D.
|
| 192 |
+
"""
|
| 193 |
+
tiled = pixel_values.dim() == 5
|
| 194 |
+
if tiled:
|
| 195 |
+
B, T, C, H, W = pixel_values.shape
|
| 196 |
+
pv = pixel_values.reshape(B * T, C, H, W)
|
| 197 |
+
if tile_mask is None:
|
| 198 |
+
raise ValueError("tile_mask is required for tiled (5-D) pixel_values.")
|
| 199 |
+
else:
|
| 200 |
+
B, T = pixel_values.size(0), 1
|
| 201 |
+
pv = pixel_values
|
| 202 |
+
|
| 203 |
+
vis_dtype = next(self.visual_encoder.parameters()).dtype
|
| 204 |
+
vis_out = self.visual_encoder(pixel_values=pv.to(vis_dtype))
|
| 205 |
+
patches = vis_out.last_hidden_state if hasattr(vis_out, "last_hidden_state") else vis_out[0]
|
| 206 |
+
patches = patches.float()
|
| 207 |
+
|
| 208 |
+
if patches.dim() == 4: # (B, C, H, W) spatial output
|
| 209 |
+
patches = F.adaptive_avg_pool2d(patches, (16, 16)).flatten(2).transpose(1, 2)
|
| 210 |
+
|
| 211 |
+
if int(self.cfg.pixel_shuffle_r) > 1:
|
| 212 |
+
patches = self._pixel_shuffle(patches)
|
| 213 |
+
|
| 214 |
+
n_per_tile, vis_dim = patches.size(1), patches.size(2)
|
| 215 |
+
if tiled:
|
| 216 |
+
patches = patches.reshape(B, T * n_per_tile, vis_dim)
|
| 217 |
+
|
| 218 |
+
pseudo = self.connect(patches)
|
| 219 |
+
max_pos = self.text_backbone.config.max_position_embeddings
|
| 220 |
+
|
| 221 |
+
if tiled:
|
| 222 |
+
attn = tile_mask.unsqueeze(-1).expand(B, T, n_per_tile).reshape(B, T * n_per_tile)
|
| 223 |
+
attn = attn.to(pseudo.device).long()
|
| 224 |
+
else:
|
| 225 |
+
attn = torch.ones(B, pseudo.size(1), device=pseudo.device, dtype=torch.long)
|
| 226 |
+
|
| 227 |
+
if self.cfg.pool_type == "eos":
|
| 228 |
+
if pseudo.size(1) > max_pos - 1:
|
| 229 |
+
pseudo, attn = pseudo[:, : max_pos - 1], attn[:, : max_pos - 1]
|
| 230 |
+
eos = self.eos_token.expand(B, 1, -1).to(pseudo.dtype)
|
| 231 |
+
pseudo = torch.cat([pseudo, eos], dim=1)
|
| 232 |
+
attn = torch.cat([attn, torch.ones(B, 1, device=attn.device, dtype=torch.long)], dim=1)
|
| 233 |
+
elif pseudo.size(1) > max_pos:
|
| 234 |
+
pseudo, attn = pseudo[:, :max_pos], attn[:, :max_pos]
|
| 235 |
+
|
| 236 |
+
text_out = self.text_backbone(inputs_embeds=pseudo, attention_mask=attn)
|
| 237 |
+
if self.cfg.pool_type == "eos":
|
| 238 |
+
pooled = text_out.last_hidden_state[:, -1, :]
|
| 239 |
+
else:
|
| 240 |
+
m = attn.unsqueeze(-1).float()
|
| 241 |
+
pooled = (text_out.last_hidden_state * m).sum(1) / m.sum(1).clamp(min=1)
|
| 242 |
+
|
| 243 |
+
emb = F.normalize(self.proj(pooled), p=2, dim=-1)
|
| 244 |
+
return NanoVDRDocOutput(embedding=emb) if return_dict else (emb,)
|
| 245 |
+
|
| 246 |
+
# -- convenience -------------------------------------------------------
|
| 247 |
+
@torch.no_grad()
|
| 248 |
+
def encode(
|
| 249 |
+
self,
|
| 250 |
+
images: Union["Image.Image", Sequence["Image.Image"]], # noqa: F821
|
| 251 |
+
processor,
|
| 252 |
+
batch_size: int = 8,
|
| 253 |
+
device: Optional[Union[str, torch.device]] = None,
|
| 254 |
+
) -> torch.Tensor:
|
| 255 |
+
"""Tile, preprocess and encode PIL pages. Returns (N, embed_dim) on CPU."""
|
| 256 |
+
from PIL import Image # local import; PIL is not a hard dependency of import
|
| 257 |
+
|
| 258 |
+
if isinstance(images, Image.Image):
|
| 259 |
+
images = [images]
|
| 260 |
+
device = device or next(self.parameters()).device
|
| 261 |
+
cfg, out = self.cfg, []
|
| 262 |
+
self.eval()
|
| 263 |
+
|
| 264 |
+
for start in range(0, len(images), batch_size):
|
| 265 |
+
chunk = images[start : start + batch_size]
|
| 266 |
+
px_batch, mask_batch = [], []
|
| 267 |
+
for img in chunk:
|
| 268 |
+
tiles = dynamic_tile(
|
| 269 |
+
img.convert("RGB"),
|
| 270 |
+
min_num=cfg.tile_min_num,
|
| 271 |
+
max_num=cfg.tile_max_num,
|
| 272 |
+
image_size=cfg.image_size,
|
| 273 |
+
use_thumbnail=cfg.tile_use_thumbnail,
|
| 274 |
+
)[: cfg.tile_max_total]
|
| 275 |
+
px = processor(images=tiles, return_tensors="pt")["pixel_values"]
|
| 276 |
+
padded = torch.zeros(cfg.tile_max_total, 3, cfg.image_size, cfg.image_size, dtype=px.dtype)
|
| 277 |
+
padded[: px.size(0)] = px
|
| 278 |
+
m = torch.zeros(cfg.tile_max_total, dtype=torch.bool)
|
| 279 |
+
m[: px.size(0)] = True
|
| 280 |
+
px_batch.append(padded)
|
| 281 |
+
mask_batch.append(m)
|
| 282 |
+
emb = self(
|
| 283 |
+
pixel_values=torch.stack(px_batch).to(device),
|
| 284 |
+
tile_mask=torch.stack(mask_batch).to(device),
|
| 285 |
+
).embedding
|
| 286 |
+
out.append(emb.float().cpu())
|
| 287 |
+
return torch.cat(out, dim=0)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
AutoConfig.register("nanovdr_doc", NanoVDRDocConfig)
|
| 291 |
+
AutoModel.register(NanoVDRDocConfig, NanoVDRDocModel)
|
preprocessor_config.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"crop_size": {
|
| 3 |
+
"height": 448,
|
| 4 |
+
"width": 448
|
| 5 |
+
},
|
| 6 |
+
"do_center_crop": true,
|
| 7 |
+
"do_convert_rgb": true,
|
| 8 |
+
"do_normalize": true,
|
| 9 |
+
"do_rescale": true,
|
| 10 |
+
"do_resize": true,
|
| 11 |
+
"image_mean": [
|
| 12 |
+
0.485,
|
| 13 |
+
0.456,
|
| 14 |
+
0.406
|
| 15 |
+
],
|
| 16 |
+
"image_processor_type": "CLIPImageProcessor",
|
| 17 |
+
"image_std": [
|
| 18 |
+
0.229,
|
| 19 |
+
0.224,
|
| 20 |
+
0.225
|
| 21 |
+
],
|
| 22 |
+
"resample": 3,
|
| 23 |
+
"rescale_factor": 0.00392156862745098,
|
| 24 |
+
"size": {
|
| 25 |
+
"shortest_edge": 448
|
| 26 |
+
}
|
| 27 |
+
}
|