Ryenhails commited on
Commit
47f2941
·
verified ·
1 Parent(s): 70b9276

NanoVDR-D-HiRes: 457M document tower distilled from Qwen3-VL-Embedding-8B

Browse files
Files changed (5) hide show
  1. README.md +189 -0
  2. config.json +24 -0
  3. model.safetensors +3 -0
  4. modeling_nanovdr_doc.py +291 -0
  5. 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> &nbsp;|&nbsp;
24
+ <a href="https://github.com/Ryenhails/NanoVDR">Code</a> &nbsp;|&nbsp;
25
+ <a href="https://huggingface.co/spaces/nanovdr/NanoVDR-Demo">Demo</a> &nbsp;|&nbsp;
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
+ }