Zero-Shot Classification
Transformers
Safetensors
qwen3_5
feature-extraction
decision-model
classification
system-one
multimodal
vision
video
custom_code
Eval Results
Instructions to use vllm-sr/d3-nano with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use vllm-sr/d3-nano with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("zero-shot-classification", model="vllm-sr/d3-nano", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoProcessor, AutoModel processor = AutoProcessor.from_pretrained("vllm-sr/d3-nano", trust_remote_code=True) model = AutoModel.from_pretrained("vllm-sr/d3-nano", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
d3-nano v3.1.0
Browse files- MODEL_MANIFEST.json +23 -21
- README.md +43 -6
- assets/example-video.mp4 +0 -0
- d3_engine.py +6 -5
- d3_runtime.py +711 -15
- d3_server.py +53 -11
- modeling_d3.py +12 -6
- pipeline_d3.py +21 -7
- requirements.txt +3 -2
MODEL_MANIFEST.json
CHANGED
|
@@ -554,14 +554,14 @@
|
|
| 554 |
},
|
| 555 |
"runtime": {
|
| 556 |
"files_sha256": {
|
| 557 |
-
"d3_runtime.py": "
|
| 558 |
"d3_fast.py": "68a1fb9a2c3bde9ac2763bf504decae592ca12722698fbde1b39a31262955535",
|
| 559 |
"d3_kernels.py": "8f7534ca1e1846ebf7c4c73058002f3be4dbf0d5d426f0815150ae0cbe7677bd",
|
| 560 |
-
"modeling_d3.py": "
|
| 561 |
-
"pipeline_d3.py": "
|
| 562 |
-
"d3_server.py": "
|
| 563 |
-
"d3_engine.py": "
|
| 564 |
-
"requirements.txt": "
|
| 565 |
"d3_format.py": "187a0f359f6f89a844e723377769556f78133b118851e702e4b0fe5123ccb0f1"
|
| 566 |
}
|
| 567 |
},
|
|
@@ -578,31 +578,32 @@
|
|
| 578 |
}
|
| 579 |
]
|
| 580 |
},
|
| 581 |
-
"built_utc": "2026-10-11T04:
|
| 582 |
"files_sha256": {
|
| 583 |
"LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
|
| 584 |
"NOTICE": "300b6105c41a1843653e40be644c3d54e06288b25199373e04143530cc3db366",
|
| 585 |
-
"README.md": "
|
| 586 |
"assets/banner.png": "cfd3c0d984881e917b33d22d011a086f5ba7a7d16e95769e5bc03c5e508d790a",
|
| 587 |
"assets/example-receipt.png": "b9dbb8b103bacd45f1de824d844a3c07b81d57cb1a7466712ef060ad7dcb3926",
|
|
|
|
| 588 |
"assets/index-areas.png": "a290ace23e12c01ae96d0959aa8e715cf01d22e9ff09e3314db74b0f1a4f8449",
|
| 589 |
"assets/index-pareto.png": "8df8991d5bd83d82ce20db40318c2fc190e4768c9163e01ce31440194fcde910",
|
| 590 |
"chat_template.jinja": "273d8e0e683b885071fb17e08d71e5f2a5ddfb5309756181681de4f5a1822d80",
|
| 591 |
"config.json": "9caa6ab8956c859c3852328dda6322893a18b30826d3916db91d938d6c74ac4f",
|
| 592 |
-
"d3_engine.py": "
|
| 593 |
"d3_fast.py": "68a1fb9a2c3bde9ac2763bf504decae592ca12722698fbde1b39a31262955535",
|
| 594 |
"d3_format.py": "187a0f359f6f89a844e723377769556f78133b118851e702e4b0fe5123ccb0f1",
|
| 595 |
"d3_kernels.py": "8f7534ca1e1846ebf7c4c73058002f3be4dbf0d5d426f0815150ae0cbe7677bd",
|
| 596 |
-
"d3_runtime.py": "
|
| 597 |
-
"d3_server.py": "
|
| 598 |
"decision_config.json": "9175a0f42d5886def9c8429fdf8b0be58a3c97d106b94520e2ac38fe326302bf",
|
| 599 |
"merges.txt": "a9d356d7bdf1ef4949e3e748e95b8e10ad9d4e2e838eddc38a0a7b6b94d1db8d",
|
| 600 |
"model.safetensors": "7269b3260f1106f3443bfdb028a76143be2e679db3f29a31f4dfd1d595f03155",
|
| 601 |
-
"modeling_d3.py": "
|
| 602 |
-
"pipeline_d3.py": "
|
| 603 |
"preprocessor_config.json": "27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516",
|
| 604 |
"readout.safetensors": "662e8a26d8016b2d479f1ca66bf64944603316c2b5e074ab7ac3304b8ae045e9",
|
| 605 |
-
"requirements.txt": "
|
| 606 |
"tokenizer.json": "5f9e4d4901a92b997e463c1f46055088b6cca5ca61a6522d1b9f64c4bb81cb42",
|
| 607 |
"tokenizer_config.json": "49e2b6e395f959f077f1e992b338919c0d4a9732fc6e613995e06557f843500c",
|
| 608 |
"video_preprocessor_config.json": "7768af27c1fafa9cc9011c1dc20067e03f8915e03b63504550e11d5066986d13",
|
|
@@ -611,27 +612,28 @@
|
|
| 611 |
"files_bytes": {
|
| 612 |
"LICENSE": 11357,
|
| 613 |
"NOTICE": 104,
|
| 614 |
-
"README.md":
|
| 615 |
"assets/banner.png": 2233242,
|
| 616 |
"assets/example-receipt.png": 65918,
|
|
|
|
| 617 |
"assets/index-areas.png": 124987,
|
| 618 |
"assets/index-pareto.png": 184438,
|
| 619 |
"chat_template.jinja": 7755,
|
| 620 |
"config.json": 2885,
|
| 621 |
-
"d3_engine.py":
|
| 622 |
"d3_fast.py": 22331,
|
| 623 |
"d3_format.py": 4881,
|
| 624 |
"d3_kernels.py": 16129,
|
| 625 |
-
"d3_runtime.py":
|
| 626 |
-
"d3_server.py":
|
| 627 |
"decision_config.json": 5502,
|
| 628 |
"merges.txt": 3353259,
|
| 629 |
"model.safetensors": 4426555160,
|
| 630 |
-
"modeling_d3.py":
|
| 631 |
-
"pipeline_d3.py":
|
| 632 |
"preprocessor_config.json": 390,
|
| 633 |
"readout.safetensors": 2089040,
|
| 634 |
-
"requirements.txt":
|
| 635 |
"tokenizer.json": 12807982,
|
| 636 |
"tokenizer_config.json": 16709,
|
| 637 |
"video_preprocessor_config.json": 385,
|
|
|
|
| 554 |
},
|
| 555 |
"runtime": {
|
| 556 |
"files_sha256": {
|
| 557 |
+
"d3_runtime.py": "6c049aa7d3da8da3738602a2812572879d0a47b03c28c3e33f36d7300257de89",
|
| 558 |
"d3_fast.py": "68a1fb9a2c3bde9ac2763bf504decae592ca12722698fbde1b39a31262955535",
|
| 559 |
"d3_kernels.py": "8f7534ca1e1846ebf7c4c73058002f3be4dbf0d5d426f0815150ae0cbe7677bd",
|
| 560 |
+
"modeling_d3.py": "f8f8db23ff58f1a990ffa24977d744a50e1b4446d02a64c22070967705b6dbef",
|
| 561 |
+
"pipeline_d3.py": "4f9297c7c74e596e9b67df91a1e6823cfada48bf781336f141974ff6a2f1038e",
|
| 562 |
+
"d3_server.py": "f9b8f2481461a074f469b4f58da3b0d7004824096528366d5b1f23187dd6e692",
|
| 563 |
+
"d3_engine.py": "086238942762df45047a32cd9a8e2bedd9b63167eb40be260c776de567979f53",
|
| 564 |
+
"requirements.txt": "57a1938448bc240bdf4fee5d1b9cbc8450109fa1f6cee11e94dc04f274be8a32",
|
| 565 |
"d3_format.py": "187a0f359f6f89a844e723377769556f78133b118851e702e4b0fe5123ccb0f1"
|
| 566 |
}
|
| 567 |
},
|
|
|
|
| 578 |
}
|
| 579 |
]
|
| 580 |
},
|
| 581 |
+
"built_utc": "2026-10-11T04:33:06+00:00",
|
| 582 |
"files_sha256": {
|
| 583 |
"LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
|
| 584 |
"NOTICE": "300b6105c41a1843653e40be644c3d54e06288b25199373e04143530cc3db366",
|
| 585 |
+
"README.md": "65356c023e4a7f6881ba36c761e997f834543caded50d9fc45737176ba49d510",
|
| 586 |
"assets/banner.png": "cfd3c0d984881e917b33d22d011a086f5ba7a7d16e95769e5bc03c5e508d790a",
|
| 587 |
"assets/example-receipt.png": "b9dbb8b103bacd45f1de824d844a3c07b81d57cb1a7466712ef060ad7dcb3926",
|
| 588 |
+
"assets/example-video.mp4": "f4968ef89d07e97f20df4fd8047510a7a8e9df5c2d07c35e363a97927f164ab9",
|
| 589 |
"assets/index-areas.png": "a290ace23e12c01ae96d0959aa8e715cf01d22e9ff09e3314db74b0f1a4f8449",
|
| 590 |
"assets/index-pareto.png": "8df8991d5bd83d82ce20db40318c2fc190e4768c9163e01ce31440194fcde910",
|
| 591 |
"chat_template.jinja": "273d8e0e683b885071fb17e08d71e5f2a5ddfb5309756181681de4f5a1822d80",
|
| 592 |
"config.json": "9caa6ab8956c859c3852328dda6322893a18b30826d3916db91d938d6c74ac4f",
|
| 593 |
+
"d3_engine.py": "086238942762df45047a32cd9a8e2bedd9b63167eb40be260c776de567979f53",
|
| 594 |
"d3_fast.py": "68a1fb9a2c3bde9ac2763bf504decae592ca12722698fbde1b39a31262955535",
|
| 595 |
"d3_format.py": "187a0f359f6f89a844e723377769556f78133b118851e702e4b0fe5123ccb0f1",
|
| 596 |
"d3_kernels.py": "8f7534ca1e1846ebf7c4c73058002f3be4dbf0d5d426f0815150ae0cbe7677bd",
|
| 597 |
+
"d3_runtime.py": "6c049aa7d3da8da3738602a2812572879d0a47b03c28c3e33f36d7300257de89",
|
| 598 |
+
"d3_server.py": "f9b8f2481461a074f469b4f58da3b0d7004824096528366d5b1f23187dd6e692",
|
| 599 |
"decision_config.json": "9175a0f42d5886def9c8429fdf8b0be58a3c97d106b94520e2ac38fe326302bf",
|
| 600 |
"merges.txt": "a9d356d7bdf1ef4949e3e748e95b8e10ad9d4e2e838eddc38a0a7b6b94d1db8d",
|
| 601 |
"model.safetensors": "7269b3260f1106f3443bfdb028a76143be2e679db3f29a31f4dfd1d595f03155",
|
| 602 |
+
"modeling_d3.py": "f8f8db23ff58f1a990ffa24977d744a50e1b4446d02a64c22070967705b6dbef",
|
| 603 |
+
"pipeline_d3.py": "4f9297c7c74e596e9b67df91a1e6823cfada48bf781336f141974ff6a2f1038e",
|
| 604 |
"preprocessor_config.json": "27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516",
|
| 605 |
"readout.safetensors": "662e8a26d8016b2d479f1ca66bf64944603316c2b5e074ab7ac3304b8ae045e9",
|
| 606 |
+
"requirements.txt": "57a1938448bc240bdf4fee5d1b9cbc8450109fa1f6cee11e94dc04f274be8a32",
|
| 607 |
"tokenizer.json": "5f9e4d4901a92b997e463c1f46055088b6cca5ca61a6522d1b9f64c4bb81cb42",
|
| 608 |
"tokenizer_config.json": "49e2b6e395f959f077f1e992b338919c0d4a9732fc6e613995e06557f843500c",
|
| 609 |
"video_preprocessor_config.json": "7768af27c1fafa9cc9011c1dc20067e03f8915e03b63504550e11d5066986d13",
|
|
|
|
| 612 |
"files_bytes": {
|
| 613 |
"LICENSE": 11357,
|
| 614 |
"NOTICE": 104,
|
| 615 |
+
"README.md": 7846,
|
| 616 |
"assets/banner.png": 2233242,
|
| 617 |
"assets/example-receipt.png": 65918,
|
| 618 |
+
"assets/example-video.mp4": 26300,
|
| 619 |
"assets/index-areas.png": 124987,
|
| 620 |
"assets/index-pareto.png": 184438,
|
| 621 |
"chat_template.jinja": 7755,
|
| 622 |
"config.json": 2885,
|
| 623 |
+
"d3_engine.py": 4413,
|
| 624 |
"d3_fast.py": 22331,
|
| 625 |
"d3_format.py": 4881,
|
| 626 |
"d3_kernels.py": 16129,
|
| 627 |
+
"d3_runtime.py": 88800,
|
| 628 |
+
"d3_server.py": 10100,
|
| 629 |
"decision_config.json": 5502,
|
| 630 |
"merges.txt": 3353259,
|
| 631 |
"model.safetensors": 4426555160,
|
| 632 |
+
"modeling_d3.py": 12079,
|
| 633 |
+
"pipeline_d3.py": 2936,
|
| 634 |
"preprocessor_config.json": 390,
|
| 635 |
"readout.safetensors": 2089040,
|
| 636 |
+
"requirements.txt": 679,
|
| 637 |
"tokenizer.json": 12807982,
|
| 638 |
"tokenizer_config.json": 16709,
|
| 639 |
"video_preprocessor_config.json": 385,
|
README.md
CHANGED
|
@@ -10,6 +10,7 @@ tags:
|
|
| 10 |
- system-one
|
| 11 |
- multimodal
|
| 12 |
- vision
|
|
|
|
| 13 |
- safetensors
|
| 14 |
---
|
| 15 |
|
|
@@ -17,12 +18,12 @@ tags:
|
|
| 17 |
|
| 18 |
# d3-nano
|
| 19 |
|
| 20 |
-
**d3-nano** is the 2B multimodal foundation decision model of Decision 3.0, the decision models of [vLLM Semantic Router](https://github.com/vllm-project/semantic-router). Give it an input (text or JSON, optionally with images) and the questions you need answered: pick one of several options, say yes or no, or rate on a scale. It answers them all in one call and returns a probability for every answer, without generating text.
|
| 21 |
|
| 22 |
| | |
|
| 23 |
| --- | --- |
|
| 24 |
| **Parameters** | 2.21B, including the 0.33B vision encoder |
|
| 25 |
-
| **Inputs** | Text or JSON, plus images (several per request) |
|
| 26 |
| **Decision types** | Choice · Yes / No · Score |
|
| 27 |
| **License** | Apache-2.0 |
|
| 28 |
|
|
@@ -31,13 +32,14 @@ tags:
|
|
| 31 |
- **Jev Decision Index 0.3, public suite: 42.22**, measured with the official 0.3 kit on the released weights: all 140,178 public requests answered, none unsupported.
|
| 32 |
- **+12.2 on the public suite over Decision 2.0** (its 2B model: 30.05 on the board), ahead in all five areas.
|
| 33 |
- **Reads images:** multiple images per request (PNG, JPEG or WebP), given as paths, URLs, PIL images or base64 data URLs; every question of the request sees all of them.
|
| 34 |
-
- **
|
|
|
|
| 35 |
- **Many questions, one call:** Choice, Yes / No and Score questions about the same input are answered together, each from its own forward pass over the input, with a probability for every option.
|
| 36 |
|
| 37 |
## Quickstart
|
| 38 |
|
| 39 |
```bash
|
| 40 |
-
pip install "transformers==5.17.0" torch torchvision pillow safetensors accelerate
|
| 41 |
pip install flash-linear-attention # optional: fast GPU kernels for the linear-attention layers
|
| 42 |
```
|
| 43 |
|
|
@@ -111,11 +113,34 @@ result = model.system_one(
|
|
| 111 |
)
|
| 112 |
print(json.dumps(result["answers"], indent=2))
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
# Or as a pipeline:
|
| 115 |
-
# transformers.pipeline("decision", model="vllm-sr/d3-nano", trust_remote_code=True)(state=..., questions=..., images=...)
|
| 116 |
```
|
| 117 |
|
| 118 |
-
Images go before the text of the request, each read at up to 1.6 megapixels;
|
| 119 |
|
| 120 |
## Evaluation
|
| 121 |
|
|
@@ -157,6 +182,18 @@ d3-nano on the public vision benchmarks († approximate rebuild):
|
|
| 157 |
| R-Bench-M | 14.8 |
|
| 158 |
| MMMU-Pro vision | 18.6 |
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
<sub>d3-nano: internal evaluation. Others: live board data, text 2026-10-10, vision 2026-10-09.</sub>
|
| 161 |
|
| 162 |
## License
|
|
|
|
| 10 |
- system-one
|
| 11 |
- multimodal
|
| 12 |
- vision
|
| 13 |
+
- video
|
| 14 |
- safetensors
|
| 15 |
---
|
| 16 |
|
|
|
|
| 18 |
|
| 19 |
# d3-nano
|
| 20 |
|
| 21 |
+
**d3-nano** is the 2B multimodal foundation decision model of Decision 3.0, the decision models of [vLLM Semantic Router](https://github.com/vllm-project/semantic-router). Give it an input (text or JSON, optionally with images and videos) and the questions you need answered: pick one of several options, say yes or no, or rate on a scale. It answers them all in one call and returns a probability for every answer, without generating text.
|
| 22 |
|
| 23 |
| | |
|
| 24 |
| --- | --- |
|
| 25 |
| **Parameters** | 2.21B, including the 0.33B vision encoder |
|
| 26 |
+
| **Inputs** | Text or JSON, plus images and videos (several per request) |
|
| 27 |
| **Decision types** | Choice · Yes / No · Score |
|
| 28 |
| **License** | Apache-2.0 |
|
| 29 |
|
|
|
|
| 32 |
- **Jev Decision Index 0.3, public suite: 42.22**, measured with the official 0.3 kit on the released weights: all 140,178 public requests answered, none unsupported.
|
| 33 |
- **+12.2 on the public suite over Decision 2.0** (its 2B model: 30.05 on the board), ahead in all five areas.
|
| 34 |
- **Reads images:** multiple images per request (PNG, JPEG or WebP), given as paths, URLs, PIL images or base64 data URLs; every question of the request sees all of them.
|
| 35 |
+
- **Reads videos:** multiple videos per request (MP4, WebM, MOV or MKV), given as paths, URLs, base64 data URLs or frame arrays, read at 2 frames per second; **Perception Test (multiple-choice video QA): 63.5** (internal evaluation).
|
| 36 |
+
- **Speed:** a median of 12.9 ms for a text request, 73.8 ms for a request with an image and 344.9 ms for a request with a 10-second video, on one AMD Instinct MI325X GPU, one request at a time.
|
| 37 |
- **Many questions, one call:** Choice, Yes / No and Score questions about the same input are answered together, each from its own forward pass over the input, with a probability for every option.
|
| 38 |
|
| 39 |
## Quickstart
|
| 40 |
|
| 41 |
```bash
|
| 42 |
+
pip install "transformers==5.17.0" torch torchvision pillow opencv-python-headless safetensors accelerate
|
| 43 |
pip install flash-linear-attention # optional: fast GPU kernels for the linear-attention layers
|
| 44 |
```
|
| 45 |
|
|
|
|
| 113 |
)
|
| 114 |
print(json.dumps(result["answers"], indent=2))
|
| 115 |
|
| 116 |
+
# Text and a video (or several)
|
| 117 |
+
clip = hf_hub_download("vllm-sr/d3-nano", "assets/example-video.mp4")
|
| 118 |
+
result = model.system_one(
|
| 119 |
+
state="A short clip from a test camera.",
|
| 120 |
+
videos=[clip], # local paths, http(s) URLs, base64 data URLs or frame arrays
|
| 121 |
+
questions={
|
| 122 |
+
"direction": {
|
| 123 |
+
"type": "choice",
|
| 124 |
+
"instructions": "Which way does the square move?",
|
| 125 |
+
"criteria": {
|
| 126 |
+
"right": "From left to right",
|
| 127 |
+
"left": "From right to left",
|
| 128 |
+
"still": "It does not move"
|
| 129 |
+
}
|
| 130 |
+
},
|
| 131 |
+
"color_change": {
|
| 132 |
+
"type": "noul",
|
| 133 |
+
"instructions": "Does the square change color?"
|
| 134 |
+
}
|
| 135 |
+
},
|
| 136 |
+
)
|
| 137 |
+
print(json.dumps(result["answers"], indent=2))
|
| 138 |
+
|
| 139 |
# Or as a pipeline:
|
| 140 |
+
# transformers.pipeline("decision", model="vllm-sr/d3-nano", trust_remote_code=True)(state=..., questions=..., images=..., videos=...)
|
| 141 |
```
|
| 142 |
|
| 143 |
+
Images go before the text of the request, each read at up to 1.6 megapixels; videos follow the images, read at 2 frames per second (at most 32 frames spread over the whole video, each at up to 0.2 megapixels). Every question of the request sees all of them.
|
| 144 |
|
| 145 |
## Evaluation
|
| 146 |
|
|
|
|
| 182 |
| R-Bench-M | 14.8 |
|
| 183 |
| MMMU-Pro vision | 18.6 |
|
| 184 |
|
| 185 |
+
### Videos: Perception Test
|
| 186 |
+
|
| 187 |
+
| Perception Test (validation) | d3-nano |
|
| 188 |
+
| --- | ---: |
|
| 189 |
+
| **All 19,140 questions** | **63.5** |
|
| 190 |
+
| Memory | 53.8 |
|
| 191 |
+
| Abstraction | 55.7 |
|
| 192 |
+
| Physics | 56.0 |
|
| 193 |
+
| Semantics | 81.0 |
|
| 194 |
+
|
| 195 |
+
Multiple-choice video question answering (three options per question, chance 33.3): top-1 accuracy (%), videos read with the defaults above.
|
| 196 |
+
|
| 197 |
<sub>d3-nano: internal evaluation. Others: live board data, text 2026-10-10, vision 2026-10-09.</sub>
|
| 198 |
|
| 199 |
## License
|
assets/example-video.mp4
ADDED
|
Binary file (26.3 kB). View file
|
|
|
d3_engine.py
CHANGED
|
@@ -11,7 +11,8 @@ average, see d3_runtime.py). A request with a question over the checkpoint's inp
|
|
| 11 |
(nothing is truncated).
|
| 12 |
|
| 13 |
Image requests: ``engine(state, questions, images=[...])`` with any number of images (PIL images, paths,
|
| 14 |
-
http(s) or data URLs) that every question sees.
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
from __future__ import annotations
|
|
@@ -37,8 +38,8 @@ class D3Engine(Engine):
|
|
| 37 |
name = "d3"
|
| 38 |
latency = (
|
| 39 |
"Device-synchronized in-process request wall time including prompt rendering and tokenization (and, "
|
| 40 |
-
"for image requests,
|
| 41 |
-
"order in batches of batch_size; excludes model loading."
|
| 42 |
)
|
| 43 |
|
| 44 |
def __init__(
|
|
@@ -90,8 +91,8 @@ class D3Engine(Engine):
|
|
| 90 |
def synchronize(self):
|
| 91 |
self.decision.synchronize()
|
| 92 |
|
| 93 |
-
def __call__(self, state, questions, images=None):
|
| 94 |
-
prepared = self.decision.prepare(state, questions, images)
|
| 95 |
over = [
|
| 96 |
e["message"]
|
| 97 |
for e in prepared.errors.values()
|
|
|
|
| 11 |
(nothing is truncated).
|
| 12 |
|
| 13 |
Image requests: ``engine(state, questions, images=[...])`` with any number of images (PIL images, paths,
|
| 14 |
+
http(s) or data URLs) that every question sees. Video requests: ``engine(state, questions, videos=[...])``
|
| 15 |
+
(with or without images) with any number of videos (paths, http(s) or data URLs, frame arrays).
|
| 16 |
"""
|
| 17 |
|
| 18 |
from __future__ import annotations
|
|
|
|
| 38 |
name = "d3"
|
| 39 |
latency = (
|
| 40 |
"Device-synchronized in-process request wall time including prompt rendering and tokenization (and, "
|
| 41 |
+
"for image and video requests, their decoding and preprocessing); one request per call, its questions in "
|
| 42 |
+
"request order in batches of batch_size; excludes model loading."
|
| 43 |
)
|
| 44 |
|
| 45 |
def __init__(
|
|
|
|
| 91 |
def synchronize(self):
|
| 92 |
self.decision.synchronize()
|
| 93 |
|
| 94 |
+
def __call__(self, state, questions, images=None, videos=None):
|
| 95 |
+
prepared = self.decision.prepare(state, questions, images, videos)
|
| 96 |
over = [
|
| 97 |
e["message"]
|
| 98 |
for e in prepared.errors.values()
|
d3_runtime.py
CHANGED
|
@@ -11,6 +11,7 @@ probability for every option. No text is generated and no input is truncated.
|
|
| 11 |
# "confidence": ...}}, "usage": {"input_tokens": n, "output_tokens": 0}}
|
| 12 |
|
| 13 |
model.system_one(state="...", questions={...}, images=["photo.png", "label.jpg"])
|
|
|
|
| 14 |
|
| 15 |
The checkpoint directory holds ``config.json`` + ``model*.safetensors`` (a transformers
|
| 16 |
``Qwen3_5Model``, or a ``Qwen3VLModel`` when ``config.json`` says ``model_type: qwen3_vl``),
|
|
@@ -26,6 +27,14 @@ the aspect ratio; each 32 x 32 pixel patch is one input token. The prompt is the
|
|
| 26 |
images in front; a request without images takes exactly the text path. Image inputs need a checkpoint
|
| 27 |
with the vision tower (``visual.*`` weights).
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
Numerics: BF16 backbone with SDPA attention, FP32 readout and softmax (unless the checkpoint says
|
| 30 |
otherwise). A request's questions run in request order, ``batch_size`` per forward pass, each batch
|
| 31 |
left-padded to its longest prompt. ``noncausal_full_attention`` (Qwen3.5 backbones only) lets the
|
|
@@ -122,6 +131,22 @@ IMAGE_INPUTS = (
|
|
| 122 |
"pixel_values",
|
| 123 |
"image_grid_thw",
|
| 124 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
class MaxLengthExceeded(ValueError):
|
|
@@ -239,6 +264,28 @@ def image_messages(
|
|
| 239 |
raise ValueError(f"unknown prompt family {prompt!r}")
|
| 240 |
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
def reversed_question(question: Question) -> dict[str, Any] | None:
|
| 243 |
"""The rendered choice question with its options in reverse order (None when there is nothing to permute)."""
|
| 244 |
if question.kind != "choice" or len(question.keys) < 2:
|
|
@@ -332,7 +379,7 @@ def _data_url_payload(value: str, strict: bool) -> bytes:
|
|
| 332 |
raise ValueError("invalid base64 image data") from exc
|
| 333 |
|
| 334 |
|
| 335 |
-
def _download(url: str) -> bytes:
|
| 336 |
import urllib.request
|
| 337 |
|
| 338 |
request = urllib.request.Request(url, headers={"User-Agent": RUNTIME})
|
|
@@ -340,7 +387,7 @@ def _download(url: str) -> bytes:
|
|
| 340 |
payload = response.read(MAX_DOWNLOAD_BYTES + 1)
|
| 341 |
if len(payload) > MAX_DOWNLOAD_BYTES:
|
| 342 |
raise ValueError(
|
| 343 |
-
f"the
|
| 344 |
)
|
| 345 |
return payload
|
| 346 |
|
|
@@ -450,6 +497,278 @@ def linearize_patch_embed(model) -> int:
|
|
| 450 |
return count
|
| 451 |
|
| 452 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
# ---------------------------------------------------------------------------------------------
|
| 454 |
# Package files
|
| 455 |
# ---------------------------------------------------------------------------------------------
|
|
@@ -684,7 +1003,9 @@ class Prepared:
|
|
| 684 |
|
| 685 |
A text request holds token ``sequences``; an image request holds the decoded ``images`` (shared by
|
| 686 |
every question), the rendered prompt ``texts`` and the planned input ``lengths`` (text tokens plus
|
| 687 |
-
image tokens), and the processor tokenizes it when it runs.
|
|
|
|
|
|
|
| 688 |
"""
|
| 689 |
|
| 690 |
keys: list[str]
|
|
@@ -694,6 +1015,9 @@ class Prepared:
|
|
| 694 |
images: list[Any] = field(default_factory=list)
|
| 695 |
texts: dict[str, str] = field(default_factory=dict)
|
| 696 |
lengths: dict[str, int] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
| 697 |
# Reversed-option prompts of the choice questions (permutation_average only).
|
| 698 |
reversed_sequences: dict[str, list[int]] = field(default_factory=dict)
|
| 699 |
reversed_texts: dict[str, str] = field(default_factory=dict)
|
|
@@ -832,6 +1156,22 @@ class D3:
|
|
| 832 |
self.image_unavailable = (
|
| 833 |
f"the image processor failed to load ({type(exc).__name__}: {exc})"
|
| 834 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 835 |
fast, reason = d3_fast, "d3_fast.py is not present"
|
| 836 |
if fast is None:
|
| 837 |
try:
|
|
@@ -900,6 +1240,36 @@ class D3:
|
|
| 900 |
enable_thinking=False,
|
| 901 |
)
|
| 902 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
def load_images(self, images: Sequence[Any], *, strict: bool = False) -> list[Any]:
|
| 904 |
"""Decode a request's images (RGB PIL), any number; raises ValueError for an image it cannot read."""
|
| 905 |
if not images:
|
|
@@ -921,11 +1291,14 @@ class D3:
|
|
| 921 |
state: Any,
|
| 922 |
questions: Mapping[str, Any],
|
| 923 |
images: Sequence[Any] | None = None,
|
|
|
|
| 924 |
) -> Prepared:
|
| 925 |
-
"""Validate and tokenize one request; malformed ``state``, ``questions`` or ``
|
|
|
|
| 926 |
|
| 927 |
-
``images`` (a list of any number of images shared by every question) select the image path
|
| 928 |
-
|
|
|
|
| 929 |
"""
|
| 930 |
if not isinstance(questions, Mapping) or not questions:
|
| 931 |
raise ValueError(
|
|
@@ -936,6 +1309,15 @@ class D3:
|
|
| 936 |
_json_value(state)
|
| 937 |
if images is not None and not isinstance(images, (list, tuple)):
|
| 938 |
raise ValueError("images must be a list of images")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 939 |
if images:
|
| 940 |
return self._prepare_images(state, questions, self.load_images(images))
|
| 941 |
prepared = Prepared(keys=list(questions))
|
|
@@ -976,9 +1358,10 @@ class D3:
|
|
| 976 |
) -> None:
|
| 977 |
"""Render and tokenize the reversed-option prompt of every runnable choice question.
|
| 978 |
|
| 979 |
-
``visual``: the image tokens of the request (image
|
|
|
|
| 980 |
"""
|
| 981 |
-
n_images = len(prepared.images)
|
| 982 |
texts = []
|
| 983 |
for key in prepared.runnable:
|
| 984 |
flipped = reversed_question(prepared.questions[key])
|
|
@@ -987,18 +1370,22 @@ class D3:
|
|
| 987 |
(
|
| 988 |
key,
|
| 989 |
(
|
| 990 |
-
self.
|
| 991 |
-
if
|
| 992 |
-
else
|
|
|
|
|
|
|
|
|
|
|
|
|
| 993 |
),
|
| 994 |
)
|
| 995 |
)
|
| 996 |
if not texts:
|
| 997 |
return
|
| 998 |
-
tokenizer = self.processor.tokenizer if n_images else self.tokenizer
|
| 999 |
ids = tokenizer([t for _, t in texts], add_special_tokens=False)["input_ids"]
|
| 1000 |
for (key, text), sequence in zip(texts, ids):
|
| 1001 |
-
length = len(sequence) - n_images + visual
|
| 1002 |
if self.max_length is not None and length > self.max_length:
|
| 1003 |
for planned in (prepared.sequences, prepared.texts, prepared.lengths):
|
| 1004 |
planned.pop(key, None)
|
|
@@ -1008,7 +1395,7 @@ class D3:
|
|
| 1008 |
"message": f"the reversed-option prompt has {length} tokens, over the maximum context "
|
| 1009 |
f"length of {self.max_length} tokens; nothing was truncated",
|
| 1010 |
}
|
| 1011 |
-
elif n_images:
|
| 1012 |
prepared.reversed_texts[key] = text
|
| 1013 |
prepared.reversed_lengths[key] = length
|
| 1014 |
else:
|
|
@@ -1069,6 +1456,125 @@ class D3:
|
|
| 1069 |
self._prepare_reversed(state, prepared, sum(visual))
|
| 1070 |
return prepared
|
| 1071 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1072 |
def logits(self, sequences: Sequence[Sequence[int]], counts: Sequence[int]):
|
| 1073 |
"""Masked code logits (FP32, [B, 255]) for one left-padded batch."""
|
| 1074 |
torch = self.torch
|
|
@@ -1196,8 +1702,170 @@ class D3:
|
|
| 1196 |
prepared.reversed_lengths.values()
|
| 1197 |
)
|
| 1198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1199 |
def run(self, prepared: Prepared) -> tuple[dict[str, list[float]], int]:
|
| 1200 |
"""Probabilities per runnable question (request order, ``batch_size`` per pass) and the input tokens."""
|
|
|
|
|
|
|
| 1201 |
if prepared.images:
|
| 1202 |
return self._run_images(prepared)
|
| 1203 |
keys = prepared.runnable
|
|
@@ -1255,15 +1923,17 @@ class D3:
|
|
| 1255 |
state: Any,
|
| 1256 |
questions: Mapping[str, Any],
|
| 1257 |
images: Sequence[Any] | None = None,
|
|
|
|
| 1258 |
) -> dict[str, Any]:
|
| 1259 |
"""Typed Choice / Noul / Score answers about one state: ``{"model", "answers", "usage"}``.
|
| 1260 |
|
| 1261 |
``images``: any number of images (PIL images, paths, http(s) or data URLs) that every question sees.
|
|
|
|
| 1262 |
A question that cannot be answered gets ``{"type", "error", "message"}`` with ``error`` one of
|
| 1263 |
``invalid_question``, ``max_length_exceeded`` (never truncated) or ``invalid_model_output``; the
|
| 1264 |
other questions of the request are still answered.
|
| 1265 |
"""
|
| 1266 |
-
prepared = self.prepare(state, questions, images)
|
| 1267 |
probabilities, tokens = self.run(prepared)
|
| 1268 |
return self.respond(prepared, probabilities, tokens)
|
| 1269 |
|
|
@@ -1359,6 +2029,7 @@ class D3:
|
|
| 1359 |
"over the question's codes only; argmax choice; no truncation (over-limit questions are "
|
| 1360 |
"refused); no option filtering; one fixed prompt for every request.",
|
| 1361 |
"images": self.image_contract(),
|
|
|
|
| 1362 |
}
|
| 1363 |
if self.permutation_average:
|
| 1364 |
record["permutation_average"] = True
|
|
@@ -1389,6 +2060,31 @@ class D3:
|
|
| 1389 |
contract["unavailable"] = self.image_unavailable
|
| 1390 |
return contract
|
| 1391 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1392 |
def runtime_info(self) -> dict[str, Any]:
|
| 1393 |
import transformers
|
| 1394 |
|
|
|
|
| 11 |
# "confidence": ...}}, "usage": {"input_tokens": n, "output_tokens": 0}}
|
| 12 |
|
| 13 |
model.system_one(state="...", questions={...}, images=["photo.png", "label.jpg"])
|
| 14 |
+
model.system_one(state="...", questions={...}, videos=["clip.mp4"])
|
| 15 |
|
| 16 |
The checkpoint directory holds ``config.json`` + ``model*.safetensors`` (a transformers
|
| 17 |
``Qwen3_5Model``, or a ``Qwen3VLModel`` when ``config.json`` says ``model_type: qwen3_vl``),
|
|
|
|
| 27 |
images in front; a request without images takes exactly the text path. Image inputs need a checkpoint
|
| 28 |
with the vision tower (``visual.*`` weights).
|
| 29 |
|
| 30 |
+
Videos: a request also takes any number of videos (local paths, http(s) URLs, base64 ``data:video/...``
|
| 31 |
+
URLs, or frame arrays: a uint8 ``[frames, height, width, 3]`` RGB array or a list of PIL images, read as
|
| 32 |
+
frames sampled at 2 per second), shared by all of its questions and placed after the images. Files are
|
| 33 |
+
decoded with OpenCV (FFmpeg): 2 frames per second, at least 4 and at most 32 frames spread evenly over the
|
| 34 |
+
whole video, each read by the checkpoint's own video processor at up to 200,704 pixels (0.2 MP); every two
|
| 35 |
+
frames are one group of input tokens (one token per 32 x 32 pixels) after a timestamp. All videos of one
|
| 36 |
+
request take at most 16,384 input tokens. A request without videos takes exactly the text or image path.
|
| 37 |
+
|
| 38 |
Numerics: BF16 backbone with SDPA attention, FP32 readout and softmax (unless the checkpoint says
|
| 39 |
otherwise). A request's questions run in request order, ``batch_size`` per forward pass, each batch
|
| 40 |
left-padded to its longest prompt. ``noncausal_full_attention`` (Qwen3.5 backbones only) lets the
|
|
|
|
| 131 |
"pixel_values",
|
| 132 |
"image_grid_thw",
|
| 133 |
)
|
| 134 |
+
VIDEO_FPS = 2.0
|
| 135 |
+
VIDEO_MIN_FRAMES = 4
|
| 136 |
+
VIDEO_MAX_FRAMES = 32
|
| 137 |
+
# Per frame; one input token per 32 x 32 pixels for every two frames.
|
| 138 |
+
VIDEO_MAX_PIXELS = 200_704
|
| 139 |
+
VIDEO_MIN_PIXELS = 4_096
|
| 140 |
+
# All videos of one request.
|
| 141 |
+
VIDEO_MAX_TOKENS = 16_384
|
| 142 |
+
# Checked for every video the server receives (strict loading).
|
| 143 |
+
MAX_VIDEO_BYTES = 32_000_000
|
| 144 |
+
MAX_VIDEO_SOURCE_PIXELS = 8_294_400
|
| 145 |
+
MAX_VIDEO_SECONDS = 300
|
| 146 |
+
VIDEO_FORMATS = ("mp4", "webm", "quicktime", "x-matroska")
|
| 147 |
+
# A source frame rate outside this range is treated as unknown.
|
| 148 |
+
VIDEO_SOURCE_FPS = (0.1, 1000.0)
|
| 149 |
+
VIDEO_UNKNOWN_FPS = 24.0
|
| 150 |
|
| 151 |
|
| 152 |
class MaxLengthExceeded(ValueError):
|
|
|
|
| 264 |
raise ValueError(f"unknown prompt family {prompt!r}")
|
| 265 |
|
| 266 |
|
| 267 |
+
def video_messages(
|
| 268 |
+
prompt: str,
|
| 269 |
+
state: Any,
|
| 270 |
+
question: dict[str, Any],
|
| 271 |
+
codes: Sequence[str],
|
| 272 |
+
n_images: int,
|
| 273 |
+
n_videos: int,
|
| 274 |
+
) -> list[dict[str, Any]]:
|
| 275 |
+
"""The prompt family's messages with the image placeholders, then the video placeholders, before the user text."""
|
| 276 |
+
if n_videos < 1:
|
| 277 |
+
raise ValueError(f"a video prompt takes at least 1 video, got {n_videos}")
|
| 278 |
+
media: list[dict[str, Any]] = [{"type": "image"} for _ in range(n_images)]
|
| 279 |
+
media += [{"type": "video"} for _ in range(n_videos)]
|
| 280 |
+
if prompt == "d3":
|
| 281 |
+
text = {"type": "text", "text": user_prompt(state, question, codes)}
|
| 282 |
+
return [
|
| 283 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 284 |
+
{"role": "user", "content": media + [text]},
|
| 285 |
+
]
|
| 286 |
+
raise ValueError(f"unknown prompt family {prompt!r}")
|
| 287 |
+
|
| 288 |
+
|
| 289 |
def reversed_question(question: Question) -> dict[str, Any] | None:
|
| 290 |
"""The rendered choice question with its options in reverse order (None when there is nothing to permute)."""
|
| 291 |
if question.kind != "choice" or len(question.keys) < 2:
|
|
|
|
| 379 |
raise ValueError("invalid base64 image data") from exc
|
| 380 |
|
| 381 |
|
| 382 |
+
def _download(url: str, what: str = "image") -> bytes:
|
| 383 |
import urllib.request
|
| 384 |
|
| 385 |
request = urllib.request.Request(url, headers={"User-Agent": RUNTIME})
|
|
|
|
| 387 |
payload = response.read(MAX_DOWNLOAD_BYTES + 1)
|
| 388 |
if len(payload) > MAX_DOWNLOAD_BYTES:
|
| 389 |
raise ValueError(
|
| 390 |
+
f"the {what} at {url} is larger than {MAX_DOWNLOAD_BYTES:,} bytes"
|
| 391 |
)
|
| 392 |
return payload
|
| 393 |
|
|
|
|
| 497 |
return count
|
| 498 |
|
| 499 |
|
| 500 |
+
# ---------------------------------------------------------------------------------------------
|
| 501 |
+
# Videos
|
| 502 |
+
# ---------------------------------------------------------------------------------------------
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
@dataclass
|
| 506 |
+
class Video:
|
| 507 |
+
"""A decoded video: the sampled RGB frames and where they come from in the source.
|
| 508 |
+
|
| 509 |
+
``frames`` is a uint8 array ``[frames, height, width, 3]`` holding an even number of frames (the last
|
| 510 |
+
one is repeated when needed); ``indices`` are their source frame numbers and ``fps`` the source frame
|
| 511 |
+
rate, which give each frame its timestamp.
|
| 512 |
+
"""
|
| 513 |
+
|
| 514 |
+
frames: Any
|
| 515 |
+
fps: float
|
| 516 |
+
indices: list[int]
|
| 517 |
+
total_frames: int
|
| 518 |
+
|
| 519 |
+
@property
|
| 520 |
+
def size(self) -> tuple[int, int]:
|
| 521 |
+
"""(width, height) of the frames."""
|
| 522 |
+
return int(self.frames.shape[2]), int(self.frames.shape[1])
|
| 523 |
+
|
| 524 |
+
def metadata(self) -> dict[str, Any]:
|
| 525 |
+
"""The video processor's ``video_metadata`` for these frames."""
|
| 526 |
+
width, height = self.size
|
| 527 |
+
return {
|
| 528 |
+
"total_num_frames": self.total_frames,
|
| 529 |
+
"fps": self.fps,
|
| 530 |
+
"width": width,
|
| 531 |
+
"height": height,
|
| 532 |
+
"duration": self.total_frames / self.fps,
|
| 533 |
+
"frames_indices": list(self.indices),
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def video_sample_indices(total: int, fps: float) -> list[int]:
|
| 538 |
+
"""Source frames to read: 2 per second, at least 4 and at most 32, spread evenly over the whole video."""
|
| 539 |
+
import numpy as np
|
| 540 |
+
|
| 541 |
+
count = int(total / fps * VIDEO_FPS)
|
| 542 |
+
count = min(max(count, VIDEO_MIN_FRAMES), VIDEO_MAX_FRAMES, total)
|
| 543 |
+
return np.linspace(0, total - 1, count).round().astype(int).tolist()
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
def _video(frames: list[Any], indices: list[int], fps: float, total: int) -> Video:
|
| 547 |
+
import numpy as np
|
| 548 |
+
|
| 549 |
+
if len(frames) % 2:
|
| 550 |
+
frames.append(frames[-1])
|
| 551 |
+
indices.append(indices[-1])
|
| 552 |
+
try:
|
| 553 |
+
stacked = np.stack(frames)
|
| 554 |
+
except ValueError as exc:
|
| 555 |
+
raise ValueError("all frames of a video must have the same size") from exc
|
| 556 |
+
return Video(np.ascontiguousarray(stacked), float(fps), list(indices), int(total))
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
def _video_data_url_payload(value: str, strict: bool) -> bytes:
|
| 560 |
+
header, separator, encoded = value.partition(",")
|
| 561 |
+
kind = header.strip().lower()
|
| 562 |
+
if (
|
| 563 |
+
not separator
|
| 564 |
+
or not kind.startswith("data:video/")
|
| 565 |
+
or not kind.endswith(";base64")
|
| 566 |
+
):
|
| 567 |
+
raise ValueError("a data URL video is data:video/<format>;base64,<data>")
|
| 568 |
+
if strict:
|
| 569 |
+
if kind[len("data:video/") :].split(";")[0] not in VIDEO_FORMATS:
|
| 570 |
+
raise ValueError(
|
| 571 |
+
"videos must be base64 MP4, WebM, QuickTime or Matroska data URLs"
|
| 572 |
+
)
|
| 573 |
+
if len(encoded) > 4 * -(-MAX_VIDEO_BYTES // 3):
|
| 574 |
+
raise ValueError(f"each video must be at most {MAX_VIDEO_BYTES:,} bytes")
|
| 575 |
+
try:
|
| 576 |
+
payload = base64.b64decode(encoded, validate=True)
|
| 577 |
+
except binascii.Error as exc:
|
| 578 |
+
raise ValueError("invalid base64 video data") from exc
|
| 579 |
+
if strict and len(payload) > MAX_VIDEO_BYTES:
|
| 580 |
+
raise ValueError(f"each video must be at most {MAX_VIDEO_BYTES:,} bytes")
|
| 581 |
+
return payload
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def _decode_video(
|
| 585 |
+
source: bytes | str, *, strict: bool, end_frame: int | None = None
|
| 586 |
+
) -> Video:
|
| 587 |
+
"""Decode a video file (path) or encoded video (bytes) with OpenCV and sample its frames."""
|
| 588 |
+
try:
|
| 589 |
+
import cv2
|
| 590 |
+
except ImportError as exc:
|
| 591 |
+
raise ValueError(
|
| 592 |
+
"decoding video files needs OpenCV (pip install opencv-python-headless)"
|
| 593 |
+
) from exc
|
| 594 |
+
import tempfile
|
| 595 |
+
|
| 596 |
+
try:
|
| 597 |
+
api = (
|
| 598 |
+
cv2.CAP_FFMPEG
|
| 599 |
+
if cv2.videoio_registry.hasBackend(cv2.CAP_FFMPEG)
|
| 600 |
+
else cv2.CAP_ANY
|
| 601 |
+
)
|
| 602 |
+
except (AttributeError, cv2.error):
|
| 603 |
+
api = cv2.CAP_ANY
|
| 604 |
+
temporary = None
|
| 605 |
+
if not isinstance(source, str):
|
| 606 |
+
# Encoded bytes are read from a temporary file, so they decode exactly as the same file given by path.
|
| 607 |
+
with tempfile.NamedTemporaryFile(prefix="d3-video-", delete=False) as handle:
|
| 608 |
+
handle.write(source)
|
| 609 |
+
source = temporary = handle.name
|
| 610 |
+
|
| 611 |
+
def capture():
|
| 612 |
+
return cv2.VideoCapture(source, api)
|
| 613 |
+
|
| 614 |
+
frames, kept, reader = [], [], None
|
| 615 |
+
try:
|
| 616 |
+
reader = capture()
|
| 617 |
+
if not reader.isOpened():
|
| 618 |
+
raise ValueError("the video could not be opened")
|
| 619 |
+
width = int(reader.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 620 |
+
height = int(reader.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 621 |
+
if width <= 0 or height <= 0:
|
| 622 |
+
raise ValueError("the video has no frame size")
|
| 623 |
+
if strict and width * height > MAX_VIDEO_SOURCE_PIXELS:
|
| 624 |
+
raise ValueError(
|
| 625 |
+
f"video frames must have at most {MAX_VIDEO_SOURCE_PIXELS:,} pixels"
|
| 626 |
+
)
|
| 627 |
+
fps = float(reader.get(cv2.CAP_PROP_FPS) or 0.0)
|
| 628 |
+
if not (
|
| 629 |
+
math.isfinite(fps) and VIDEO_SOURCE_FPS[0] <= fps <= VIDEO_SOURCE_FPS[1]
|
| 630 |
+
):
|
| 631 |
+
fps = VIDEO_UNKNOWN_FPS
|
| 632 |
+
total = int(reader.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
| 633 |
+
if total <= 0: # the container does not say: count the frames, then read again
|
| 634 |
+
limit = int(MAX_VIDEO_SECONDS * fps) if strict else None
|
| 635 |
+
while reader.grab():
|
| 636 |
+
total += 1
|
| 637 |
+
if limit is not None and total > limit:
|
| 638 |
+
break
|
| 639 |
+
reader.release()
|
| 640 |
+
reader = capture()
|
| 641 |
+
if end_frame is not None:
|
| 642 |
+
total = min(total, int(end_frame))
|
| 643 |
+
if total <= 0:
|
| 644 |
+
raise ValueError("the video has no frames")
|
| 645 |
+
if strict and total / fps > MAX_VIDEO_SECONDS:
|
| 646 |
+
raise ValueError(
|
| 647 |
+
f"each video must be at most {MAX_VIDEO_SECONDS} seconds long"
|
| 648 |
+
)
|
| 649 |
+
indices = video_sample_indices(total, fps)
|
| 650 |
+
wanted = set(indices)
|
| 651 |
+
for number in range(indices[-1] + 1):
|
| 652 |
+
if not reader.grab():
|
| 653 |
+
continue
|
| 654 |
+
if number in wanted:
|
| 655 |
+
ok, frame = reader.retrieve()
|
| 656 |
+
if ok and frame is not None and frame.size:
|
| 657 |
+
frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
|
| 658 |
+
kept.append(number)
|
| 659 |
+
except cv2.error as exc:
|
| 660 |
+
raise ValueError(f"invalid video data ({exc.__class__.__name__})") from exc
|
| 661 |
+
finally:
|
| 662 |
+
if reader is not None:
|
| 663 |
+
reader.release()
|
| 664 |
+
if temporary is not None:
|
| 665 |
+
os.unlink(temporary)
|
| 666 |
+
if not frames:
|
| 667 |
+
raise ValueError("no frame of the video could be decoded")
|
| 668 |
+
return _video(frames, kept, fps, total)
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
def _frames_video(value: Any, end_frame: int | None = None) -> Video:
|
| 672 |
+
"""A frame array as a video sampled at ``VIDEO_FPS``, thinned evenly to at most ``VIDEO_MAX_FRAMES``."""
|
| 673 |
+
import numpy as np
|
| 674 |
+
from PIL import Image
|
| 675 |
+
|
| 676 |
+
if hasattr(value, "detach"):
|
| 677 |
+
value = value.detach().cpu().numpy()
|
| 678 |
+
if isinstance(value, (list, tuple)):
|
| 679 |
+
if not value:
|
| 680 |
+
raise ValueError("a video given as frames needs at least one frame")
|
| 681 |
+
value = [
|
| 682 |
+
(
|
| 683 |
+
np.asarray(frame.convert("RGB"))
|
| 684 |
+
if isinstance(frame, Image.Image)
|
| 685 |
+
else np.asarray(frame)
|
| 686 |
+
)
|
| 687 |
+
for frame in value
|
| 688 |
+
]
|
| 689 |
+
if len({frame.shape for frame in value}) != 1:
|
| 690 |
+
raise ValueError("all frames of a video must have the same size")
|
| 691 |
+
value = np.stack(value)
|
| 692 |
+
array = np.asarray(value)
|
| 693 |
+
if (
|
| 694 |
+
array.ndim != 4
|
| 695 |
+
or array.shape[-1] != 3
|
| 696 |
+
or array.dtype != np.uint8
|
| 697 |
+
or not len(array)
|
| 698 |
+
):
|
| 699 |
+
raise ValueError(
|
| 700 |
+
"a video given as frames is a uint8 [frames, height, width, 3] RGB array or a list of "
|
| 701 |
+
"PIL images or [height, width, 3] arrays"
|
| 702 |
+
)
|
| 703 |
+
if end_frame is not None:
|
| 704 |
+
array = array[: max(1, int(end_frame))]
|
| 705 |
+
total = len(array)
|
| 706 |
+
if total > VIDEO_MAX_FRAMES:
|
| 707 |
+
indices = (
|
| 708 |
+
np.linspace(0, total - 1, VIDEO_MAX_FRAMES).round().astype(int).tolist()
|
| 709 |
+
)
|
| 710 |
+
else:
|
| 711 |
+
indices = list(range(total))
|
| 712 |
+
return _video([array[i] for i in indices], indices, VIDEO_FPS, total)
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
def load_video(
|
| 716 |
+
value: Any, *, strict: bool = False, end_frame: int | None = None
|
| 717 |
+
) -> Video:
|
| 718 |
+
"""One video input, decoded and sampled (see the module docstring).
|
| 719 |
+
|
| 720 |
+
``value`` is a local path, an http(s) URL, a ``data:video/<format>;base64,`` URL, a frame array (a uint8
|
| 721 |
+
``[frames, height, width, 3]`` RGB array or a list of PIL images or ``[height, width, 3]`` arrays, read
|
| 722 |
+
as frames sampled at 2 per second) or a ``Video`` this function returned. ``end_frame`` reads only the
|
| 723 |
+
frames before it. ``strict`` (the server) accepts only data URLs of MP4, WebM, QuickTime or Matroska
|
| 724 |
+
videos of at most 32,000,000 bytes, 300 seconds and 8,294,400 pixels per frame.
|
| 725 |
+
"""
|
| 726 |
+
if isinstance(value, Video) and not strict:
|
| 727 |
+
return value
|
| 728 |
+
if isinstance(value, os.PathLike):
|
| 729 |
+
value = os.fspath(value)
|
| 730 |
+
if not isinstance(value, str):
|
| 731 |
+
if strict:
|
| 732 |
+
raise ValueError(
|
| 733 |
+
"videos must be base64 MP4, WebM, QuickTime or Matroska data URLs"
|
| 734 |
+
)
|
| 735 |
+
return _frames_video(value, end_frame)
|
| 736 |
+
if not value:
|
| 737 |
+
raise ValueError(
|
| 738 |
+
"a video is a path, an http(s) URL, a data:video/...;base64 URL or a frame array"
|
| 739 |
+
)
|
| 740 |
+
if strict and not value.startswith("data:"):
|
| 741 |
+
raise ValueError(
|
| 742 |
+
"videos must be base64 MP4, WebM, QuickTime or Matroska data URLs"
|
| 743 |
+
)
|
| 744 |
+
try:
|
| 745 |
+
if value.startswith("data:"):
|
| 746 |
+
source: bytes | str = _video_data_url_payload(value, strict)
|
| 747 |
+
elif value.startswith(("http://", "https://")):
|
| 748 |
+
source = _download(value, "video")
|
| 749 |
+
else:
|
| 750 |
+
path = Path(value).expanduser()
|
| 751 |
+
if not path.is_file():
|
| 752 |
+
raise ValueError(f"no video file at {value}")
|
| 753 |
+
source = str(path)
|
| 754 |
+
except OSError as exc:
|
| 755 |
+
raise ValueError(f"video unreadable ({exc})") from exc
|
| 756 |
+
return _decode_video(source, strict=strict, end_frame=end_frame)
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
def configure_video_processor(processor) -> None:
|
| 760 |
+
"""Read already-sampled frames at up to ``VIDEO_MAX_PIXELS`` each with the checkpoint's video processor."""
|
| 761 |
+
video = processor.video_processor
|
| 762 |
+
factor = video.patch_size * video.merge_size
|
| 763 |
+
video.size = {
|
| 764 |
+
"shortest_edge": VIDEO_MIN_PIXELS,
|
| 765 |
+
"longest_edge": VIDEO_MAX_PIXELS * VIDEO_MAX_FRAMES,
|
| 766 |
+
}
|
| 767 |
+
video.cap_pixels_per_frame = True
|
| 768 |
+
video.max_video_tokens = VIDEO_MAX_PIXELS // factor**2
|
| 769 |
+
video.do_sample_frames = False
|
| 770 |
+
|
| 771 |
+
|
| 772 |
# ---------------------------------------------------------------------------------------------
|
| 773 |
# Package files
|
| 774 |
# ---------------------------------------------------------------------------------------------
|
|
|
|
| 1003 |
|
| 1004 |
A text request holds token ``sequences``; an image request holds the decoded ``images`` (shared by
|
| 1005 |
every question), the rendered prompt ``texts`` and the planned input ``lengths`` (text tokens plus
|
| 1006 |
+
image tokens), and the processor tokenizes it when it runs. A video request also holds the decoded
|
| 1007 |
+
``videos``, their processed pixels (``media``, one copy per image and video) and each video's
|
| 1008 |
+
placeholder expansion (``video_texts``).
|
| 1009 |
"""
|
| 1010 |
|
| 1011 |
keys: list[str]
|
|
|
|
| 1015 |
images: list[Any] = field(default_factory=list)
|
| 1016 |
texts: dict[str, str] = field(default_factory=dict)
|
| 1017 |
lengths: dict[str, int] = field(default_factory=dict)
|
| 1018 |
+
videos: list[Video] = field(default_factory=list)
|
| 1019 |
+
media: dict[str, Any] = field(default_factory=dict)
|
| 1020 |
+
video_texts: list[str] = field(default_factory=list)
|
| 1021 |
# Reversed-option prompts of the choice questions (permutation_average only).
|
| 1022 |
reversed_sequences: dict[str, list[int]] = field(default_factory=dict)
|
| 1023 |
reversed_texts: dict[str, str] = field(default_factory=dict)
|
|
|
|
| 1156 |
self.image_unavailable = (
|
| 1157 |
f"the image processor failed to load ({type(exc).__name__}: {exc})"
|
| 1158 |
)
|
| 1159 |
+
self.video_unavailable: str | None = None
|
| 1160 |
+
if self.image_unavailable is not None:
|
| 1161 |
+
self.video_unavailable = self.image_unavailable
|
| 1162 |
+
elif self.backbone_type != "qwen3_5":
|
| 1163 |
+
self.video_unavailable = "video inputs need a Qwen3.5 backbone"
|
| 1164 |
+
elif getattr(self.processor, "video_processor", None) is None:
|
| 1165 |
+
self.video_unavailable = "the checkpoint has no video processor"
|
| 1166 |
+
else:
|
| 1167 |
+
try:
|
| 1168 |
+
configure_video_processor(self.processor)
|
| 1169 |
+
except (
|
| 1170 |
+
Exception
|
| 1171 |
+
) as exc: # noqa: BLE001 - text and image requests do not use it
|
| 1172 |
+
self.video_unavailable = (
|
| 1173 |
+
f"the video processor failed to load ({type(exc).__name__}: {exc})"
|
| 1174 |
+
)
|
| 1175 |
fast, reason = d3_fast, "d3_fast.py is not present"
|
| 1176 |
if fast is None:
|
| 1177 |
try:
|
|
|
|
| 1240 |
enable_thinking=False,
|
| 1241 |
)
|
| 1242 |
|
| 1243 |
+
def video_text(
|
| 1244 |
+
self, state: Any, question: dict[str, Any], n_images: int, n_videos: int
|
| 1245 |
+
) -> str:
|
| 1246 |
+
return self.processor.apply_chat_template(
|
| 1247 |
+
video_messages(
|
| 1248 |
+
self.prompt, state, question, self.codes, n_images, n_videos
|
| 1249 |
+
),
|
| 1250 |
+
tokenize=False,
|
| 1251 |
+
add_generation_prompt=True,
|
| 1252 |
+
enable_thinking=False,
|
| 1253 |
+
)
|
| 1254 |
+
|
| 1255 |
+
def load_videos(
|
| 1256 |
+
self, videos: Sequence[Any], *, strict: bool = False
|
| 1257 |
+
) -> list[Video]:
|
| 1258 |
+
"""Decode and sample a request's videos, any number; raises ValueError for a video it cannot read."""
|
| 1259 |
+
if not videos:
|
| 1260 |
+
return []
|
| 1261 |
+
if self.video_unavailable is not None:
|
| 1262 |
+
raise ValueError(
|
| 1263 |
+
f"video inputs are not available: {self.video_unavailable}"
|
| 1264 |
+
)
|
| 1265 |
+
decoded = []
|
| 1266 |
+
for number, value in enumerate(videos):
|
| 1267 |
+
try:
|
| 1268 |
+
decoded.append(load_video(value, strict=strict))
|
| 1269 |
+
except ValueError as exc:
|
| 1270 |
+
raise ValueError(f"videos[{number}]: {exc}") from exc
|
| 1271 |
+
return decoded
|
| 1272 |
+
|
| 1273 |
def load_images(self, images: Sequence[Any], *, strict: bool = False) -> list[Any]:
|
| 1274 |
"""Decode a request's images (RGB PIL), any number; raises ValueError for an image it cannot read."""
|
| 1275 |
if not images:
|
|
|
|
| 1291 |
state: Any,
|
| 1292 |
questions: Mapping[str, Any],
|
| 1293 |
images: Sequence[Any] | None = None,
|
| 1294 |
+
videos: Sequence[Any] | None = None,
|
| 1295 |
) -> Prepared:
|
| 1296 |
+
"""Validate and tokenize one request; malformed ``state``, ``questions``, ``images`` or ``videos`` raise
|
| 1297 |
+
ValueError.
|
| 1298 |
|
| 1299 |
+
``images`` (a list of any number of images shared by every question) select the image path, ``videos``
|
| 1300 |
+
(a list of any number of videos, with or without images) the video path. Without them the request takes
|
| 1301 |
+
the text path unchanged.
|
| 1302 |
"""
|
| 1303 |
if not isinstance(questions, Mapping) or not questions:
|
| 1304 |
raise ValueError(
|
|
|
|
| 1309 |
_json_value(state)
|
| 1310 |
if images is not None and not isinstance(images, (list, tuple)):
|
| 1311 |
raise ValueError("images must be a list of images")
|
| 1312 |
+
if videos is not None and not isinstance(videos, (list, tuple)):
|
| 1313 |
+
raise ValueError("videos must be a list of videos")
|
| 1314 |
+
if videos:
|
| 1315 |
+
return self._prepare_videos(
|
| 1316 |
+
state,
|
| 1317 |
+
questions,
|
| 1318 |
+
self.load_images(images or []),
|
| 1319 |
+
self.load_videos(videos),
|
| 1320 |
+
)
|
| 1321 |
if images:
|
| 1322 |
return self._prepare_images(state, questions, self.load_images(images))
|
| 1323 |
prepared = Prepared(keys=list(questions))
|
|
|
|
| 1358 |
) -> None:
|
| 1359 |
"""Render and tokenize the reversed-option prompt of every runnable choice question.
|
| 1360 |
|
| 1361 |
+
``visual``: the image and video tokens of the request (image and video paths), counted like
|
| 1362 |
+
``_prepare_images`` and ``_prepare_videos`` count them.
|
| 1363 |
"""
|
| 1364 |
+
n_images, n_videos = len(prepared.images), len(prepared.videos)
|
| 1365 |
texts = []
|
| 1366 |
for key in prepared.runnable:
|
| 1367 |
flipped = reversed_question(prepared.questions[key])
|
|
|
|
| 1370 |
(
|
| 1371 |
key,
|
| 1372 |
(
|
| 1373 |
+
self.video_text(state, flipped, n_images, n_videos)
|
| 1374 |
+
if n_videos
|
| 1375 |
+
else (
|
| 1376 |
+
self.image_text(state, flipped, n_images)
|
| 1377 |
+
if n_images
|
| 1378 |
+
else self.text(state, flipped)
|
| 1379 |
+
)
|
| 1380 |
),
|
| 1381 |
)
|
| 1382 |
)
|
| 1383 |
if not texts:
|
| 1384 |
return
|
| 1385 |
+
tokenizer = self.processor.tokenizer if n_images or n_videos else self.tokenizer
|
| 1386 |
ids = tokenizer([t for _, t in texts], add_special_tokens=False)["input_ids"]
|
| 1387 |
for (key, text), sequence in zip(texts, ids):
|
| 1388 |
+
length = len(sequence) - n_images - n_videos + visual
|
| 1389 |
if self.max_length is not None and length > self.max_length:
|
| 1390 |
for planned in (prepared.sequences, prepared.texts, prepared.lengths):
|
| 1391 |
planned.pop(key, None)
|
|
|
|
| 1395 |
"message": f"the reversed-option prompt has {length} tokens, over the maximum context "
|
| 1396 |
f"length of {self.max_length} tokens; nothing was truncated",
|
| 1397 |
}
|
| 1398 |
+
elif n_images or n_videos:
|
| 1399 |
prepared.reversed_texts[key] = text
|
| 1400 |
prepared.reversed_lengths[key] = length
|
| 1401 |
else:
|
|
|
|
| 1456 |
self._prepare_reversed(state, prepared, sum(visual))
|
| 1457 |
return prepared
|
| 1458 |
|
| 1459 |
+
def _prepare_videos(
|
| 1460 |
+
self,
|
| 1461 |
+
state: Any,
|
| 1462 |
+
questions: Mapping[str, Any],
|
| 1463 |
+
images: list[Any],
|
| 1464 |
+
videos: list[Video],
|
| 1465 |
+
) -> Prepared:
|
| 1466 |
+
"""Process the images and videos once, render every question with them in front and plan its tokens."""
|
| 1467 |
+
processor = self.processor
|
| 1468 |
+
try:
|
| 1469 |
+
visual = [
|
| 1470 |
+
visual_tokens(processor.image_processor, *image.size)
|
| 1471 |
+
for image in images
|
| 1472 |
+
]
|
| 1473 |
+
except ValueError as exc:
|
| 1474 |
+
raise ValueError(f"image rejected by the processor: {exc}") from exc
|
| 1475 |
+
merge = processor.video_processor.merge_size**2
|
| 1476 |
+
try:
|
| 1477 |
+
planned = (
|
| 1478 |
+
sum(
|
| 1479 |
+
processor.video_processor.get_num_of_video_patches(
|
| 1480 |
+
len(video.frames), video.size[1], video.size[0]
|
| 1481 |
+
)
|
| 1482 |
+
for video in videos
|
| 1483 |
+
)
|
| 1484 |
+
// merge
|
| 1485 |
+
)
|
| 1486 |
+
except ValueError as exc:
|
| 1487 |
+
raise ValueError(f"video rejected by the processor: {exc}") from exc
|
| 1488 |
+
if planned > VIDEO_MAX_TOKENS:
|
| 1489 |
+
raise ValueError(
|
| 1490 |
+
f"the videos take {planned:,} input tokens, over the {VIDEO_MAX_TOKENS:,} tokens a "
|
| 1491 |
+
"request may spend on videos; send fewer or shorter videos"
|
| 1492 |
+
)
|
| 1493 |
+
try:
|
| 1494 |
+
media = dict(
|
| 1495 |
+
processor.video_processor(
|
| 1496 |
+
videos=[video.frames for video in videos],
|
| 1497 |
+
video_metadata=[video.metadata() for video in videos],
|
| 1498 |
+
return_metadata=True,
|
| 1499 |
+
return_tensors="pt",
|
| 1500 |
+
)
|
| 1501 |
+
)
|
| 1502 |
+
except ValueError as exc:
|
| 1503 |
+
raise ValueError(f"video rejected by the processor: {exc}") from exc
|
| 1504 |
+
video_texts = [
|
| 1505 |
+
processor.replace_video_token(media, video_idx=i)
|
| 1506 |
+
for i in range(len(videos))
|
| 1507 |
+
]
|
| 1508 |
+
if int(media["video_grid_thw"].prod(-1).sum()) // merge != planned:
|
| 1509 |
+
raise RuntimeError("the video processor and its token count disagree")
|
| 1510 |
+
expanded = sum(
|
| 1511 |
+
len(ids)
|
| 1512 |
+
for ids in processor.tokenizer(video_texts, add_special_tokens=False)[
|
| 1513 |
+
"input_ids"
|
| 1514 |
+
]
|
| 1515 |
+
)
|
| 1516 |
+
media = {
|
| 1517 |
+
"pixel_values_videos": media["pixel_values_videos"],
|
| 1518 |
+
"video_grid_thw": media["video_grid_thw"],
|
| 1519 |
+
}
|
| 1520 |
+
if images:
|
| 1521 |
+
media.update(processor.image_processor(images=images, return_tensors="pt"))
|
| 1522 |
+
prepared = Prepared(
|
| 1523 |
+
keys=list(questions),
|
| 1524 |
+
images=images,
|
| 1525 |
+
videos=videos,
|
| 1526 |
+
media=media,
|
| 1527 |
+
video_texts=video_texts,
|
| 1528 |
+
)
|
| 1529 |
+
texts = []
|
| 1530 |
+
for key, question in questions.items():
|
| 1531 |
+
try:
|
| 1532 |
+
normalized = normalize_question(question)
|
| 1533 |
+
text = self.video_text(
|
| 1534 |
+
state, normalized.rendered, len(images), len(videos)
|
| 1535 |
+
)
|
| 1536 |
+
except ValueError as exc:
|
| 1537 |
+
kind = question.get("type") if isinstance(question, Mapping) else None
|
| 1538 |
+
prepared.errors[key] = {
|
| 1539 |
+
"type": kind,
|
| 1540 |
+
"error": "invalid_question",
|
| 1541 |
+
"message": str(exc),
|
| 1542 |
+
}
|
| 1543 |
+
continue
|
| 1544 |
+
if text.count(IMAGE_TOKEN) != len(images) or text.count(VIDEO_TOKEN) != len(
|
| 1545 |
+
videos
|
| 1546 |
+
):
|
| 1547 |
+
prepared.errors[key] = {
|
| 1548 |
+
"type": normalized.kind,
|
| 1549 |
+
"error": "invalid_question",
|
| 1550 |
+
"message": "the state or question contains a literal image or video placeholder token",
|
| 1551 |
+
}
|
| 1552 |
+
continue
|
| 1553 |
+
prepared.questions[key] = normalized
|
| 1554 |
+
texts.append((key, text))
|
| 1555 |
+
if texts:
|
| 1556 |
+
ids = processor.tokenizer([t for _, t in texts], add_special_tokens=False)[
|
| 1557 |
+
"input_ids"
|
| 1558 |
+
]
|
| 1559 |
+
for (key, text), sequence in zip(texts, ids):
|
| 1560 |
+
length = (
|
| 1561 |
+
len(sequence) - len(images) - len(videos) + sum(visual) + expanded
|
| 1562 |
+
)
|
| 1563 |
+
if self.max_length is not None and length > self.max_length:
|
| 1564 |
+
prepared.errors[key] = {
|
| 1565 |
+
"type": prepared.questions[key].kind,
|
| 1566 |
+
"error": "max_length_exceeded",
|
| 1567 |
+
"message": f"the question prompt has {length} tokens ({sum(visual) + expanded} for "
|
| 1568 |
+
f"{len(images)} image(s) and {len(videos)} video(s)), over the maximum context length "
|
| 1569 |
+
f"of {self.max_length} tokens; nothing was truncated",
|
| 1570 |
+
}
|
| 1571 |
+
continue
|
| 1572 |
+
prepared.texts[key] = text
|
| 1573 |
+
prepared.lengths[key] = length
|
| 1574 |
+
if self.permutation_average:
|
| 1575 |
+
self._prepare_reversed(state, prepared, sum(visual) + expanded)
|
| 1576 |
+
return prepared
|
| 1577 |
+
|
| 1578 |
def logits(self, sequences: Sequence[Sequence[int]], counts: Sequence[int]):
|
| 1579 |
"""Masked code logits (FP32, [B, 255]) for one left-padded batch."""
|
| 1580 |
torch = self.torch
|
|
|
|
| 1702 |
prepared.reversed_lengths.values()
|
| 1703 |
)
|
| 1704 |
|
| 1705 |
+
def media_features(self, media: Mapping[str, Any]) -> dict[str, list[Any]]:
|
| 1706 |
+
"""Vision-tower features of each image and video of a video request, computed once per request."""
|
| 1707 |
+
torch = self.torch
|
| 1708 |
+
backbone = self.backbone
|
| 1709 |
+
features: dict[str, list[Any]] = {}
|
| 1710 |
+
with torch.inference_mode(), sdpa_backends(self.device):
|
| 1711 |
+
if "pixel_values" in media:
|
| 1712 |
+
features["image"] = list(
|
| 1713 |
+
backbone.get_image_features(
|
| 1714 |
+
media["pixel_values"].to(self.device),
|
| 1715 |
+
media["image_grid_thw"].to(self.device),
|
| 1716 |
+
return_dict=True,
|
| 1717 |
+
).pooler_output
|
| 1718 |
+
)
|
| 1719 |
+
features["video"] = list(
|
| 1720 |
+
backbone.get_video_features(
|
| 1721 |
+
media["pixel_values_videos"].to(self.device),
|
| 1722 |
+
media["video_grid_thw"].to(self.device),
|
| 1723 |
+
return_dict=True,
|
| 1724 |
+
).pooler_output
|
| 1725 |
+
)
|
| 1726 |
+
return features
|
| 1727 |
+
|
| 1728 |
+
def video_logits(
|
| 1729 |
+
self,
|
| 1730 |
+
texts: Sequence[str],
|
| 1731 |
+
prepared: Prepared,
|
| 1732 |
+
features: Mapping[str, list[Any]],
|
| 1733 |
+
counts: Sequence[int],
|
| 1734 |
+
width: int,
|
| 1735 |
+
):
|
| 1736 |
+
"""Masked code logits (FP32, [B, 255]) for one left-padded batch of prompts that share the request's videos.
|
| 1737 |
+
|
| 1738 |
+
Each prompt's placeholders are expanded with the request's images and videos and the batch is padded to
|
| 1739 |
+
``width`` tokens (the longest planned prompt); the vision features of the request go into every prompt,
|
| 1740 |
+
then the text layers run as for an image batch (the fused layers when the fast path is active).
|
| 1741 |
+
"""
|
| 1742 |
+
torch = self.torch
|
| 1743 |
+
processor = self.processor
|
| 1744 |
+
backbone = self.backbone
|
| 1745 |
+
media = prepared.media
|
| 1746 |
+
image_texts = [
|
| 1747 |
+
processor.replace_image_token(media, image_idx=i)
|
| 1748 |
+
for i in range(len(prepared.images))
|
| 1749 |
+
]
|
| 1750 |
+
expanded = [
|
| 1751 |
+
processor.get_text_with_replacements(
|
| 1752 |
+
[text], image_texts, prepared.video_texts
|
| 1753 |
+
)[0][0]
|
| 1754 |
+
for text in texts
|
| 1755 |
+
]
|
| 1756 |
+
encoded = processor.tokenizer(
|
| 1757 |
+
expanded, padding=True, return_token_type_ids=False, return_tensors="pt"
|
| 1758 |
+
)
|
| 1759 |
+
if encoded["input_ids"].shape[1] != width:
|
| 1760 |
+
raise RuntimeError(
|
| 1761 |
+
f"planned {width} input tokens, the processor produced {encoded['input_ids'].shape[1]}"
|
| 1762 |
+
)
|
| 1763 |
+
rows = len(texts)
|
| 1764 |
+
ids = encoded["input_ids"].to(self.device)
|
| 1765 |
+
mask = encoded["attention_mask"].to(self.device)
|
| 1766 |
+
types = torch.as_tensor(
|
| 1767 |
+
processor.create_mm_token_type_ids(encoded["input_ids"]), device=self.device
|
| 1768 |
+
)
|
| 1769 |
+
padded = not bool(encoded["attention_mask"].all())
|
| 1770 |
+
with torch.inference_mode(), sdpa_backends(self.device):
|
| 1771 |
+
embeds = backbone.get_input_embeddings()(ids)
|
| 1772 |
+
image_grid = None
|
| 1773 |
+
if "image" in features:
|
| 1774 |
+
values = torch.cat(features["image"] * rows).to(
|
| 1775 |
+
embeds.device, embeds.dtype
|
| 1776 |
+
)
|
| 1777 |
+
image_mask, _ = backbone.get_placeholder_mask(
|
| 1778 |
+
ids, inputs_embeds=embeds, image_features=values
|
| 1779 |
+
)
|
| 1780 |
+
embeds = embeds.masked_scatter(image_mask, values)
|
| 1781 |
+
image_grid = media["image_grid_thw"].repeat(rows, 1).to(self.device)
|
| 1782 |
+
values = torch.cat(features["video"] * rows).to(embeds.device, embeds.dtype)
|
| 1783 |
+
_, video_mask = backbone.get_placeholder_mask(
|
| 1784 |
+
ids, inputs_embeds=embeds, video_features=values
|
| 1785 |
+
)
|
| 1786 |
+
embeds = embeds.masked_scatter(video_mask, values)
|
| 1787 |
+
positions = backbone.compute_3d_position_ids(
|
| 1788 |
+
input_ids=ids,
|
| 1789 |
+
image_grid_thw=image_grid,
|
| 1790 |
+
video_grid_thw=media["video_grid_thw"].repeat(rows, 1).to(self.device),
|
| 1791 |
+
inputs_embeds=embeds,
|
| 1792 |
+
attention_mask=mask,
|
| 1793 |
+
past_key_values=None,
|
| 1794 |
+
mm_token_type_ids=types,
|
| 1795 |
+
)
|
| 1796 |
+
fused = self.fast.fused if self.fast is not None else None
|
| 1797 |
+
if fused is not None:
|
| 1798 |
+
rotary = self.fast.lm.rotary_emb(embeds, positions)
|
| 1799 |
+
hidden = fused.forward(
|
| 1800 |
+
embeds,
|
| 1801 |
+
mask.bool()[:, None, None, :],
|
| 1802 |
+
mask.reshape(-1) if padded else None,
|
| 1803 |
+
rotary,
|
| 1804 |
+
)[:, -1]
|
| 1805 |
+
else:
|
| 1806 |
+
hidden = backbone.language_model(
|
| 1807 |
+
input_ids=None,
|
| 1808 |
+
position_ids=positions,
|
| 1809 |
+
attention_mask=mask,
|
| 1810 |
+
past_key_values=None,
|
| 1811 |
+
inputs_embeds=embeds,
|
| 1812 |
+
use_cache=False,
|
| 1813 |
+
).last_hidden_state[:, -1]
|
| 1814 |
+
if self.readout_dtype == "float32":
|
| 1815 |
+
logits = hidden.float() @ self.readout.T
|
| 1816 |
+
else:
|
| 1817 |
+
logits = torch.nn.functional.linear(hidden, self.readout).float()
|
| 1818 |
+
limit = torch.as_tensor(list(counts), device=self.device)[:, None]
|
| 1819 |
+
invalid = torch.arange(MAX_OPTIONS, device=self.device)[None] >= limit
|
| 1820 |
+
return logits.masked_fill(invalid, float("-inf"))
|
| 1821 |
+
|
| 1822 |
+
def _run_videos(self, prepared: Prepared) -> tuple[dict[str, list[float]], int]:
|
| 1823 |
+
keys = prepared.runnable
|
| 1824 |
+
out: dict[str, list[float]] = {}
|
| 1825 |
+
if not keys:
|
| 1826 |
+
return out, 0
|
| 1827 |
+
features = self.media_features(prepared.media)
|
| 1828 |
+
|
| 1829 |
+
def probabilities(texts, counts, width):
|
| 1830 |
+
probs = (
|
| 1831 |
+
(
|
| 1832 |
+
self.video_logits(texts, prepared, features, counts, width)
|
| 1833 |
+
/ self.temperature
|
| 1834 |
+
)
|
| 1835 |
+
.softmax(-1)
|
| 1836 |
+
.cpu()
|
| 1837 |
+
.tolist()
|
| 1838 |
+
)
|
| 1839 |
+
return [p[:c] for p, c in zip(probs, counts)]
|
| 1840 |
+
|
| 1841 |
+
for start in range(0, len(keys), self.batch_size):
|
| 1842 |
+
chunk = keys[start : start + self.batch_size]
|
| 1843 |
+
extra = [k for k in chunk if k in prepared.reversed_texts]
|
| 1844 |
+
texts = [prepared.texts[k] for k in chunk] + [
|
| 1845 |
+
prepared.reversed_texts[k] for k in extra
|
| 1846 |
+
]
|
| 1847 |
+
counts = [len(prepared.questions[k].keys) for k in chunk + extra]
|
| 1848 |
+
widths = [prepared.lengths[k] for k in chunk] + [
|
| 1849 |
+
prepared.reversed_lengths[k] for k in extra
|
| 1850 |
+
]
|
| 1851 |
+
n = len(chunk)
|
| 1852 |
+
if extra and len(widths) * max(widths) > MERGE_TOKENS:
|
| 1853 |
+
probs = probabilities(
|
| 1854 |
+
texts[:n], counts[:n], max(widths[:n])
|
| 1855 |
+
) + probabilities(texts[n:], counts[n:], max(widths[n:]))
|
| 1856 |
+
else:
|
| 1857 |
+
probs = probabilities(texts, counts, max(widths))
|
| 1858 |
+
out.update(zip(chunk, probs))
|
| 1859 |
+
for key, backward in zip(extra, probs[n:]):
|
| 1860 |
+
out[key] = average_orders(out[key], backward)
|
| 1861 |
+
return out, sum(prepared.lengths[k] for k in keys) + sum(
|
| 1862 |
+
prepared.reversed_lengths.values()
|
| 1863 |
+
)
|
| 1864 |
+
|
| 1865 |
def run(self, prepared: Prepared) -> tuple[dict[str, list[float]], int]:
|
| 1866 |
"""Probabilities per runnable question (request order, ``batch_size`` per pass) and the input tokens."""
|
| 1867 |
+
if prepared.videos:
|
| 1868 |
+
return self._run_videos(prepared)
|
| 1869 |
if prepared.images:
|
| 1870 |
return self._run_images(prepared)
|
| 1871 |
keys = prepared.runnable
|
|
|
|
| 1923 |
state: Any,
|
| 1924 |
questions: Mapping[str, Any],
|
| 1925 |
images: Sequence[Any] | None = None,
|
| 1926 |
+
videos: Sequence[Any] | None = None,
|
| 1927 |
) -> dict[str, Any]:
|
| 1928 |
"""Typed Choice / Noul / Score answers about one state: ``{"model", "answers", "usage"}``.
|
| 1929 |
|
| 1930 |
``images``: any number of images (PIL images, paths, http(s) or data URLs) that every question sees.
|
| 1931 |
+
``videos``: any number of videos (paths, http(s) or data URLs, frame arrays) that every question sees.
|
| 1932 |
A question that cannot be answered gets ``{"type", "error", "message"}`` with ``error`` one of
|
| 1933 |
``invalid_question``, ``max_length_exceeded`` (never truncated) or ``invalid_model_output``; the
|
| 1934 |
other questions of the request are still answered.
|
| 1935 |
"""
|
| 1936 |
+
prepared = self.prepare(state, questions, images, videos)
|
| 1937 |
probabilities, tokens = self.run(prepared)
|
| 1938 |
return self.respond(prepared, probabilities, tokens)
|
| 1939 |
|
|
|
|
| 2029 |
"over the question's codes only; argmax choice; no truncation (over-limit questions are "
|
| 2030 |
"refused); no option filtering; one fixed prompt for every request.",
|
| 2031 |
"images": self.image_contract(),
|
| 2032 |
+
"videos": self.video_contract(),
|
| 2033 |
}
|
| 2034 |
if self.permutation_average:
|
| 2035 |
record["permutation_average"] = True
|
|
|
|
| 2060 |
contract["unavailable"] = self.image_unavailable
|
| 2061 |
return contract
|
| 2062 |
|
| 2063 |
+
def video_contract(self) -> dict[str, Any]:
|
| 2064 |
+
"""How video inputs are read (or why they are not available)."""
|
| 2065 |
+
contract: dict[str, Any] = {
|
| 2066 |
+
"supported": self.video_unavailable is None,
|
| 2067 |
+
"fps": VIDEO_FPS,
|
| 2068 |
+
"min_frames": VIDEO_MIN_FRAMES,
|
| 2069 |
+
"max_frames": VIDEO_MAX_FRAMES,
|
| 2070 |
+
"max_pixels_per_frame": VIDEO_MAX_PIXELS,
|
| 2071 |
+
"max_tokens_per_request": VIDEO_MAX_TOKENS,
|
| 2072 |
+
"placement": "after the images, before the text of the user turn, one placeholder per video, "
|
| 2073 |
+
"request order; every two frames are one token group after their timestamp",
|
| 2074 |
+
"sampling": "source frames spread evenly over the whole video (frame arrays: frames at 2 per second)",
|
| 2075 |
+
}
|
| 2076 |
+
if self.video_unavailable is None:
|
| 2077 |
+
contract["video_processor"] = type(self.processor.video_processor).__name__
|
| 2078 |
+
try:
|
| 2079 |
+
import cv2
|
| 2080 |
+
|
| 2081 |
+
contract["opencv"] = cv2.__version__
|
| 2082 |
+
except Exception: # noqa: BLE001
|
| 2083 |
+
contract["opencv"] = None
|
| 2084 |
+
else:
|
| 2085 |
+
contract["unavailable"] = self.video_unavailable
|
| 2086 |
+
return contract
|
| 2087 |
+
|
| 2088 |
def runtime_info(self) -> dict[str, Any]:
|
| 2089 |
import transformers
|
| 2090 |
|
d3_server.py
CHANGED
|
@@ -3,12 +3,16 @@
|
|
| 3 |
pip install fastapi uvicorn
|
| 4 |
python d3_server.py --model <package dir or Hub id> [--device cuda:0] [--host 127.0.0.1] [--port 8000]
|
| 5 |
|
| 6 |
-
Request ``{"model", "state", "questions", "images"}``, response ``{"model", "answers", "usage"}``: the
|
| 7 |
-
format of the Decision Index ``http`` engine. ``images`` (optional) lists any number of base64 PNG, JPEG or
|
| 8 |
-
data URLs (``data:image/png;base64,...``) that every question sees, each at most 8,000,000 bytes and
|
| 9 |
-
pixels (the model reads it at up to 1.6 MP).
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
``DECISION_API_KEY`` set, requests need ``Authorization: Bearer <key>``. ``GET /health`` and
|
| 13 |
``GET /v1/models`` describe the loaded model.
|
| 14 |
|
|
@@ -35,18 +39,28 @@ os.environ.setdefault("TRITON_CACHE_AUTOTUNING", "1")
|
|
| 35 |
from d3_runtime import ( # noqa: E402
|
| 36 |
DEFAULT_BATCH_SIZE,
|
| 37 |
IMAGE_MAX_PIXELS,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
D3,
|
| 39 |
)
|
| 40 |
|
| 41 |
-
REQUEST_FIELDS = {"model", "state", "questions", "images"}
|
| 42 |
|
| 43 |
|
| 44 |
def reads_images(model: D3) -> bool:
|
| 45 |
return getattr(model, "image_unavailable", "unknown") is None
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def modalities(model: D3) -> list[str]:
|
| 49 |
-
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
class Service:
|
|
@@ -129,6 +143,14 @@ def build_app(args: argparse.Namespace):
|
|
| 129 |
}
|
| 130 |
if reads_images(model):
|
| 131 |
entry["image_max_pixels"] = IMAGE_MAX_PIXELS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
return {"models": [entry]}
|
| 133 |
|
| 134 |
def decode_images(model: D3, images: Any) -> list[Any]:
|
|
@@ -140,10 +162,25 @@ def build_app(args: argparse.Namespace):
|
|
| 140 |
raise ValueError("This model reads text only; images are not supported.")
|
| 141 |
return model.load_images(images, strict=True)
|
| 142 |
|
| 143 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
model = service.model
|
| 145 |
with service.lock:
|
| 146 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
prepared = model.prepare(
|
| 148 |
body.get("state"), body.get("questions"), images
|
| 149 |
)
|
|
@@ -181,7 +218,12 @@ def build_app(args: argparse.Namespace):
|
|
| 181 |
if body.get("images") is not None
|
| 182 |
else []
|
| 183 |
)
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
except ValueError as exc:
|
| 186 |
raise HTTPException(422, str(exc)) from exc
|
| 187 |
|
|
|
|
| 3 |
pip install fastapi uvicorn
|
| 4 |
python d3_server.py --model <package dir or Hub id> [--device cuda:0] [--host 127.0.0.1] [--port 8000]
|
| 5 |
|
| 6 |
+
Request ``{"model", "state", "questions", "images", "videos"}``, response ``{"model", "answers", "usage"}``: the
|
| 7 |
+
wire format of the Decision Index ``http`` engine. ``images`` (optional) lists any number of base64 PNG, JPEG or
|
| 8 |
+
WebP data URLs (``data:image/png;base64,...``) that every question sees, each at most 8,000,000 bytes and
|
| 9 |
+
16,000,000 pixels (the model reads it at up to 1.6 MP). ``videos`` (optional) lists any number of base64 MP4, WebM,
|
| 10 |
+
QuickTime or Matroska data URLs (``data:video/mp4;base64,...``) that every question sees, each at most 32,000,000
|
| 11 |
+
bytes, 300 seconds and 8,294,400 pixels per frame (the model reads 2 frames per second, at most 32 frames spread
|
| 12 |
+
over the video, each at up to 0.2 MP; at most 16,384 video tokens per request). A question over the input limit
|
| 13 |
+
refuses the whole request with HTTP 422 naming the maximum context length (the Index records it as unsupported;
|
| 14 |
+
nothing is truncated); malformed requests and invalid images or videos also get 422. Requests are served one at
|
| 15 |
+
a time. With
|
| 16 |
``DECISION_API_KEY`` set, requests need ``Authorization: Bearer <key>``. ``GET /health`` and
|
| 17 |
``GET /v1/models`` describe the loaded model.
|
| 18 |
|
|
|
|
| 39 |
from d3_runtime import ( # noqa: E402
|
| 40 |
DEFAULT_BATCH_SIZE,
|
| 41 |
IMAGE_MAX_PIXELS,
|
| 42 |
+
MAX_VIDEO_BYTES,
|
| 43 |
+
VIDEO_FPS,
|
| 44 |
+
VIDEO_MAX_FRAMES,
|
| 45 |
+
VIDEO_MAX_PIXELS,
|
| 46 |
+
VIDEO_MAX_TOKENS,
|
| 47 |
D3,
|
| 48 |
)
|
| 49 |
|
| 50 |
+
REQUEST_FIELDS = {"model", "state", "questions", "images", "videos"}
|
| 51 |
|
| 52 |
|
| 53 |
def reads_images(model: D3) -> bool:
|
| 54 |
return getattr(model, "image_unavailable", "unknown") is None
|
| 55 |
|
| 56 |
|
| 57 |
+
def reads_videos(model: D3) -> bool:
|
| 58 |
+
return getattr(model, "video_unavailable", "unknown") is None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
def modalities(model: D3) -> list[str]:
|
| 62 |
+
names = ["text", "image"] if reads_images(model) else ["text"]
|
| 63 |
+
return names + ["video"] if reads_videos(model) else names
|
| 64 |
|
| 65 |
|
| 66 |
class Service:
|
|
|
|
| 143 |
}
|
| 144 |
if reads_images(model):
|
| 145 |
entry["image_max_pixels"] = IMAGE_MAX_PIXELS
|
| 146 |
+
if reads_videos(model):
|
| 147 |
+
entry["video"] = {
|
| 148 |
+
"fps": VIDEO_FPS,
|
| 149 |
+
"max_frames": VIDEO_MAX_FRAMES,
|
| 150 |
+
"max_pixels_per_frame": VIDEO_MAX_PIXELS,
|
| 151 |
+
"max_tokens_per_request": VIDEO_MAX_TOKENS,
|
| 152 |
+
"max_bytes": MAX_VIDEO_BYTES,
|
| 153 |
+
}
|
| 154 |
return {"models": [entry]}
|
| 155 |
|
| 156 |
def decode_images(model: D3, images: Any) -> list[Any]:
|
|
|
|
| 162 |
raise ValueError("This model reads text only; images are not supported.")
|
| 163 |
return model.load_images(images, strict=True)
|
| 164 |
|
| 165 |
+
def decode_videos(model: D3, videos: Any) -> list[Any]:
|
| 166 |
+
if not isinstance(videos, list):
|
| 167 |
+
raise ValueError("videos must be a list of base64 data URLs")
|
| 168 |
+
if not videos:
|
| 169 |
+
return []
|
| 170 |
+
if not reads_videos(model):
|
| 171 |
+
raise ValueError("This model does not read videos.")
|
| 172 |
+
return model.load_videos(videos, strict=True)
|
| 173 |
+
|
| 174 |
+
def decide(
|
| 175 |
+
body: dict[str, Any], images: list[Any], videos: list[Any]
|
| 176 |
+
) -> dict[str, Any]:
|
| 177 |
model = service.model
|
| 178 |
with service.lock:
|
| 179 |
+
if videos:
|
| 180 |
+
prepared = model.prepare(
|
| 181 |
+
body.get("state"), body.get("questions"), images, videos
|
| 182 |
+
)
|
| 183 |
+
elif images:
|
| 184 |
prepared = model.prepare(
|
| 185 |
body.get("state"), body.get("questions"), images
|
| 186 |
)
|
|
|
|
| 218 |
if body.get("images") is not None
|
| 219 |
else []
|
| 220 |
)
|
| 221 |
+
videos = (
|
| 222 |
+
await run_in_threadpool(decode_videos, service.model, body["videos"])
|
| 223 |
+
if body.get("videos") is not None
|
| 224 |
+
else []
|
| 225 |
+
)
|
| 226 |
+
return await run_in_threadpool(decide, body, images, videos)
|
| 227 |
except ValueError as exc:
|
| 228 |
raise HTTPException(422, str(exc)) from exc
|
| 229 |
|
modeling_d3.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""d3 model for 🤗 Transformers (``trust_remote_code=True``).
|
| 2 |
|
| 3 |
``AutoModel.from_pretrained(repo, trust_remote_code=True)`` loads the repository through its own runtime
|
| 4 |
-
(``d3_runtime.py``) and returns a model with ``system_one(state=..., questions={...}, images=[...])``
|
| 5 |
-
(any number of images per request). The
|
| 6 |
repository is a standard ``Qwen3_5Model`` checkpoint (``D3Model``) or ``Qwen3VLModel`` checkpoint
|
| 7 |
(``D3Qwen3VLModel``) plus a 255-way answer-code readout, so without ``trust_remote_code`` the same
|
| 8 |
repository loads as the plain backbone. A directory without ``decision_config.json`` is not a Decision
|
|
@@ -107,7 +107,7 @@ def _is_decision(name_or_path: Any, revision: Any, hub: dict[str, Any]) -> bool:
|
|
| 107 |
|
| 108 |
|
| 109 |
class D3Model(PreTrainedModel):
|
| 110 |
-
"""A d3 checkpoint behind System One: ``system_one(state=..., questions={...}, images=[...])``."""
|
| 111 |
|
| 112 |
config_class = Qwen3_5Config
|
| 113 |
base_model_prefix = "decision"
|
|
@@ -233,16 +233,19 @@ class D3Model(PreTrainedModel):
|
|
| 233 |
state: Any,
|
| 234 |
questions: dict[str, Any],
|
| 235 |
images: list[Any] | None = None,
|
|
|
|
| 236 |
) -> dict[str, Any]:
|
| 237 |
"""Typed Choice / Noul / Score answers about one state: ``{"model", "answers", "usage"}``.
|
| 238 |
|
| 239 |
``questions`` maps question IDs to ``{"type": "choice" | "noul" | "score", "instructions": ...,
|
| 240 |
"criteria": ...}``; a question over the input limit is answered ``max_length_exceeded``, never
|
| 241 |
truncated. ``images``: any number of images every question sees (PIL images, local paths, http(s) URLs or
|
| 242 |
-
base64 ``data:image/...`` URLs), placed before the text and read at up to 1.6 MP each.
|
|
|
|
|
|
|
| 243 |
"""
|
| 244 |
return self._require().system_one(
|
| 245 |
-
state=state, questions=questions, images=images
|
| 246 |
)
|
| 247 |
|
| 248 |
def forward(
|
|
@@ -250,8 +253,11 @@ class D3Model(PreTrainedModel):
|
|
| 250 |
state: Any = None,
|
| 251 |
questions: dict[str, Any] | None = None,
|
| 252 |
images: list[Any] | None = None,
|
|
|
|
| 253 |
) -> dict[str, Any]:
|
| 254 |
-
return self.system_one(
|
|
|
|
|
|
|
| 255 |
|
| 256 |
def to(self, *args: Any, **kwargs: Any) -> D3Model:
|
| 257 |
"""Move to another device; numerics are fixed by the checkpoint, so dtype casts are refused."""
|
|
|
|
| 1 |
"""d3 model for 🤗 Transformers (``trust_remote_code=True``).
|
| 2 |
|
| 3 |
``AutoModel.from_pretrained(repo, trust_remote_code=True)`` loads the repository through its own runtime
|
| 4 |
+
(``d3_runtime.py``) and returns a model with ``system_one(state=..., questions={...}, images=[...], videos=[...])``
|
| 5 |
+
(any number of images and videos per request). The
|
| 6 |
repository is a standard ``Qwen3_5Model`` checkpoint (``D3Model``) or ``Qwen3VLModel`` checkpoint
|
| 7 |
(``D3Qwen3VLModel``) plus a 255-way answer-code readout, so without ``trust_remote_code`` the same
|
| 8 |
repository loads as the plain backbone. A directory without ``decision_config.json`` is not a Decision
|
|
|
|
| 107 |
|
| 108 |
|
| 109 |
class D3Model(PreTrainedModel):
|
| 110 |
+
"""A d3 checkpoint behind System One: ``system_one(state=..., questions={...}, images=[...], videos=[...])``."""
|
| 111 |
|
| 112 |
config_class = Qwen3_5Config
|
| 113 |
base_model_prefix = "decision"
|
|
|
|
| 233 |
state: Any,
|
| 234 |
questions: dict[str, Any],
|
| 235 |
images: list[Any] | None = None,
|
| 236 |
+
videos: list[Any] | None = None,
|
| 237 |
) -> dict[str, Any]:
|
| 238 |
"""Typed Choice / Noul / Score answers about one state: ``{"model", "answers", "usage"}``.
|
| 239 |
|
| 240 |
``questions`` maps question IDs to ``{"type": "choice" | "noul" | "score", "instructions": ...,
|
| 241 |
"criteria": ...}``; a question over the input limit is answered ``max_length_exceeded``, never
|
| 242 |
truncated. ``images``: any number of images every question sees (PIL images, local paths, http(s) URLs or
|
| 243 |
+
base64 ``data:image/...`` URLs), placed before the text and read at up to 1.6 MP each. ``videos``: any
|
| 244 |
+
number of videos every question sees (local paths, http(s) URLs, base64 ``data:video/...`` URLs or frame
|
| 245 |
+
arrays), placed after the images: 2 frames per second, at most 32 frames, each at up to 0.2 MP.
|
| 246 |
"""
|
| 247 |
return self._require().system_one(
|
| 248 |
+
state=state, questions=questions, images=images, videos=videos
|
| 249 |
)
|
| 250 |
|
| 251 |
def forward(
|
|
|
|
| 253 |
state: Any = None,
|
| 254 |
questions: dict[str, Any] | None = None,
|
| 255 |
images: list[Any] | None = None,
|
| 256 |
+
videos: list[Any] | None = None,
|
| 257 |
) -> dict[str, Any]:
|
| 258 |
+
return self.system_one(
|
| 259 |
+
state=state, questions=questions, images=images, videos=videos
|
| 260 |
+
)
|
| 261 |
|
| 262 |
def to(self, *args: Any, **kwargs: Any) -> D3Model:
|
| 263 |
"""Move to another device; numerics are fixed by the checkpoint, so dtype casts are refused."""
|
pipeline_d3.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
"""The ``decision`` pipeline for d3 models (``trust_remote_code=True``).
|
| 2 |
|
| 3 |
``pipeline("decision", model=repo, trust_remote_code=True)`` loads the model with ``AutoModel`` and answers
|
| 4 |
-
``{"state": ..., "questions": {...}}`` requests, optionally with ``"images": [...]``
|
| 5 |
-
request), or ``state=..., questions=..., images=...`` keywords, or a list of requests, with
|
|
|
|
| 6 |
``system_one`` response. The model batches the questions of one request itself.
|
| 7 |
"""
|
| 8 |
|
|
@@ -10,7 +11,7 @@ from transformers import Pipeline
|
|
| 10 |
|
| 11 |
_UNSET = object()
|
| 12 |
REQUEST_KEYS = {"state", "questions"}
|
| 13 |
-
OPTIONAL_KEYS = {"images"}
|
| 14 |
|
| 15 |
|
| 16 |
class D3Pipeline(Pipeline):
|
|
@@ -28,17 +29,28 @@ class D3Pipeline(Pipeline):
|
|
| 28 |
return {}, {}, {}
|
| 29 |
|
| 30 |
def __call__(
|
| 31 |
-
self,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
):
|
| 33 |
-
if
|
| 34 |
if inputs is not None:
|
| 35 |
-
raise TypeError(
|
|
|
|
|
|
|
| 36 |
inputs = {
|
| 37 |
"state": None if state is _UNSET else state,
|
| 38 |
"questions": None if questions is _UNSET else questions,
|
| 39 |
}
|
| 40 |
if images is not _UNSET:
|
| 41 |
inputs["images"] = images
|
|
|
|
|
|
|
| 42 |
if kwargs.get("batch_size") not in (None, 1):
|
| 43 |
raise ValueError(
|
| 44 |
"The decision pipeline runs one request at a time (batch_size=1)"
|
|
@@ -51,12 +63,13 @@ class D3Pipeline(Pipeline):
|
|
| 51 |
):
|
| 52 |
raise ValueError(
|
| 53 |
'A decision request is {"state": ..., "questions": {<id>: <question>, ...}} '
|
| 54 |
-
'with optional "images": [...]'
|
| 55 |
)
|
| 56 |
return {
|
| 57 |
"state": inputs["state"],
|
| 58 |
"questions": inputs["questions"],
|
| 59 |
"images": inputs.get("images"),
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
def _forward(self, model_inputs):
|
|
@@ -64,6 +77,7 @@ class D3Pipeline(Pipeline):
|
|
| 64 |
state=model_inputs["state"],
|
| 65 |
questions=model_inputs["questions"],
|
| 66 |
images=model_inputs["images"],
|
|
|
|
| 67 |
)
|
| 68 |
|
| 69 |
def postprocess(self, model_outputs):
|
|
|
|
| 1 |
"""The ``decision`` pipeline for d3 models (``trust_remote_code=True``).
|
| 2 |
|
| 3 |
``pipeline("decision", model=repo, trust_remote_code=True)`` loads the model with ``AutoModel`` and answers
|
| 4 |
+
``{"state": ..., "questions": {...}}`` requests, optionally with ``"images": [...]`` and ``"videos": [...]`` (any
|
| 5 |
+
number per request), or ``state=..., questions=..., images=..., videos=...`` keywords, or a list of requests, with
|
| 6 |
+
the model's
|
| 7 |
``system_one`` response. The model batches the questions of one request itself.
|
| 8 |
"""
|
| 9 |
|
|
|
|
| 11 |
|
| 12 |
_UNSET = object()
|
| 13 |
REQUEST_KEYS = {"state", "questions"}
|
| 14 |
+
OPTIONAL_KEYS = {"images", "videos"}
|
| 15 |
|
| 16 |
|
| 17 |
class D3Pipeline(Pipeline):
|
|
|
|
| 29 |
return {}, {}, {}
|
| 30 |
|
| 31 |
def __call__(
|
| 32 |
+
self,
|
| 33 |
+
inputs=None,
|
| 34 |
+
*,
|
| 35 |
+
state=_UNSET,
|
| 36 |
+
questions=_UNSET,
|
| 37 |
+
images=_UNSET,
|
| 38 |
+
videos=_UNSET,
|
| 39 |
+
**kwargs,
|
| 40 |
):
|
| 41 |
+
if any(v is not _UNSET for v in (state, questions, images, videos)):
|
| 42 |
if inputs is not None:
|
| 43 |
+
raise TypeError(
|
| 44 |
+
"Pass one request, or state=, questions=, images= and videos="
|
| 45 |
+
)
|
| 46 |
inputs = {
|
| 47 |
"state": None if state is _UNSET else state,
|
| 48 |
"questions": None if questions is _UNSET else questions,
|
| 49 |
}
|
| 50 |
if images is not _UNSET:
|
| 51 |
inputs["images"] = images
|
| 52 |
+
if videos is not _UNSET:
|
| 53 |
+
inputs["videos"] = videos
|
| 54 |
if kwargs.get("batch_size") not in (None, 1):
|
| 55 |
raise ValueError(
|
| 56 |
"The decision pipeline runs one request at a time (batch_size=1)"
|
|
|
|
| 63 |
):
|
| 64 |
raise ValueError(
|
| 65 |
'A decision request is {"state": ..., "questions": {<id>: <question>, ...}} '
|
| 66 |
+
'with optional "images": [...] and "videos": [...]'
|
| 67 |
)
|
| 68 |
return {
|
| 69 |
"state": inputs["state"],
|
| 70 |
"questions": inputs["questions"],
|
| 71 |
"images": inputs.get("images"),
|
| 72 |
+
"videos": inputs.get("videos"),
|
| 73 |
}
|
| 74 |
|
| 75 |
def _forward(self, model_inputs):
|
|
|
|
| 77 |
state=model_inputs["state"],
|
| 78 |
questions=model_inputs["questions"],
|
| 79 |
images=model_inputs["images"],
|
| 80 |
+
videos=model_inputs["videos"],
|
| 81 |
)
|
| 82 |
|
| 83 |
def postprocess(self, model_outputs):
|
requirements.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# d3 runtime. Validated with transformers 5.17.0 on ROCm (torch 2.13)
|
| 2 |
torch>=2.8
|
| 3 |
transformers==5.17.0
|
| 4 |
accelerate>=1.10
|
|
@@ -11,7 +11,8 @@ einops
|
|
| 11 |
# (install the torchvision build that matches torch; without it transformers falls back to a PIL resize).
|
| 12 |
pillow>=10
|
| 13 |
torchvision
|
| 14 |
-
#
|
|
|
|
| 15 |
# d3_server.py only:
|
| 16 |
# fastapi
|
| 17 |
# uvicorn
|
|
|
|
| 1 |
+
# d3 runtime. Validated with transformers 5.17.0 on ROCm (torch 2.13).
|
| 2 |
torch>=2.8
|
| 3 |
transformers==5.17.0
|
| 4 |
accelerate>=1.10
|
|
|
|
| 11 |
# (install the torchvision build that matches torch; without it transformers falls back to a PIL resize).
|
| 12 |
pillow>=10
|
| 13 |
torchvision
|
| 14 |
+
# Video files: OpenCV decodes them with FFmpeg (frame arrays need no decoder).
|
| 15 |
+
opencv-python-headless
|
| 16 |
# d3_server.py only:
|
| 17 |
# fastapi
|
| 18 |
# uvicorn
|