mirror of
https://github.com/opencv/opencv.git
synced 2026-10-03 04:03:30 +03:00
Added support for Granite Docling 258M and PaddleOCR-VL - #29618 Adds `com.microsoft::GroupQueryAttention` to the engine-new ONNX importer, required by GraniteDocling-258M's ,end-to-end samples for GraniteDocling-258M and PaddleOCR-VL-1.5. Verified on Linux x86_64 / GCC / CPU **companion opencv_extra PR** [Vlm ocr support engine new opencv/opencv_extra#1412](https://github.com/opencv/opencv_extra/pull/1412) for test data upstream/5.x already lowers `GroupQueryAttention` to `AttentionOnnxAi`, but that path rejects `do_rotary=1`: ```cpp CV_CheckEQ(params.get<int>("do_rotary", 0), 0, "GroupQueryAttention: do_rotary=1 is not supported"); ``` GraniteDocling-258M's export sets it. Confirmed two ways: building a Llama checkpoint from its published `text_config` (9 heads / 3 KV heads, `rope_theta=100000`) and running onnxruntime-genai's `model_builder`; and reading the community export at `onnx-community/granite-docling-258M-ONNX`, which carries 30 `GroupQueryAttention` nodes with `do_rotary=1`, `num_heads=9`, `kv_num_heads=3`, `head_dim=64`. ## Changes **Importer** (`onnx_importer2.cpp`, +49) Routing as above, plus the input validation the op needs: packed QKV rejected, past_key / past_value required as a pair, `do_rotary=1` requires cos/sin, unsupported trailing inputs rejected, and `seqlens_k` shorter than the buffer recognised as a shared-buffer export rather than silently mis-attended. **Layer** (`group_query_attention_layer.cpp`, +380) Attention is built on `fastGemmBatch` and `fused_softmax_softcap_mask`, the same kernels `attention_onnxai_layer.cpp` uses. Grouping falls out of the batched-GEMM offset table (query head → `h / groupSize`), so no broadcast copy is needed, and the second GEMM writes straight into the `[B, S, num_heads*D]` output. Supports rotary (full and partial), sliding window, softcap, growing and preallocated caches, and optional `present_*` outputs. **Core** (`persistence_json.cpp`, +92) `\uXXXX` escapes are UTF-16 code units, so anything above the BMP arrives as a surrogate pair. These were encoded as two separate 3-byte sequences CESU-8, which is not valid UTF-8 - for keys and values alike. Now recombined into one 4-byte sequence, with unpaired surrogates rejected. **Tests / perf / samples** `test_layer_group_query_attention.cpp` (+93), `test_tokenizer.cpp` (+28), `test_tokenizer.py` (+42), `perf_layer.cpp` (+125: `Layer_GroupQueryAttention` MHA_ShortCache and Grouped_WithCache, plus `Layer_Sign`), and two samples (`granite_docling_inference.py`, `paddleocr_vl_inference.py`). ### Pull Request Readiness Checklist See details at https://github.com/opencv/opencv/wiki/How_to_contribute#making-a-good-pull-request - [x] I agree to contribute to the project under Apache 2 License. - [x] To the best of my knowledge, the proposed patch is not based on a code under GPL or another license that is incompatible with OpenCV - [x] The PR is proposed to the proper branch - [x] There is a reference to the original bug report and related work - [x] There is accuracy test, performance test and test data in opencv_extra repository, if applicable Patch to opencv_extra has the same branch name. - [x] The feature is well documented and sample code can be built with the project CMake
168 lines
7.0 KiB
Python
168 lines
7.0 KiB
Python
#!/usr/bin/env python
|
|
|
|
'''
|
|
Test for Tokenizer Python bindings
|
|
'''
|
|
|
|
from __future__ import print_function
|
|
|
|
import cv2 as cv
|
|
import os
|
|
import json
|
|
|
|
from tests_common import NewOpenCVTests
|
|
|
|
def _tf(filename=""):
|
|
base = os.environ.get("OPENCV_TEST_DATA_PATH") or os.getcwd()
|
|
path = os.path.join(base, "dnn", "llm", filename)
|
|
if not os.path.exists(path):
|
|
raise FileNotFoundError(
|
|
f"Missing test data: {path}. "
|
|
"Set OPENCV_TEST_DATA_PATH to the testdata root contains dnn/llm."
|
|
)
|
|
return path
|
|
|
|
class TokenizerBindingTest(NewOpenCVTests):
|
|
def test_tokenizer_binding(self):
|
|
try:
|
|
tokenizer = cv.dnn.Tokenizer
|
|
print("Tokenizer binding is available.", tokenizer)
|
|
gpt2_model = _tf("gpt2/config.json")
|
|
tokenizer = cv.dnn.Tokenizer.load(gpt2_model)
|
|
print("Tokenizer loaded from:", gpt2_model)
|
|
except AttributeError:
|
|
self.fail("Tokenizer binding is NOT available.")
|
|
|
|
def test_tokenizer_gpt2(self):
|
|
tok = cv.dnn.Tokenizer.load((_tf("gpt2/config.json")))
|
|
ids = tok.encode("hello world")
|
|
print(ids)
|
|
txt = tok.decode(ids)
|
|
self.assertEqual(txt, "hello world")
|
|
|
|
def test_tokenizer_gpt4(self):
|
|
tok = cv.dnn.Tokenizer.load(_tf("gpt4/config.json"))
|
|
tokens = tok.encode("hello world")
|
|
self.assertEqual(list(tokens), [15339, 1917])
|
|
sent = tok.decode([15339, 1917])
|
|
self.assertEqual(sent, "hello world")
|
|
|
|
def test_tokenizer_bert_encode_chunks(self):
|
|
tok = cv.dnn.Tokenizer.load(_tf("bert/config.json"))
|
|
a = list(tok.encode("hello world"))
|
|
b = list(tok.encode("OpenCV is Great"))
|
|
pair = list(tok.encode(["hello world", "OpenCV is Great"]))
|
|
self.assertEqual(pair, a + b[1:])
|
|
self.assertEqual(pair, [101, 7592, 2088, 102, 2330, 2278, 2615, 2003, 2307, 102])
|
|
|
|
# Any number of chunks, and a one-chunk list matches the plain string call.
|
|
c = list(tok.encode("third one"))
|
|
self.assertEqual(list(tok.encode(["hello world", "OpenCV is Great", "third one"])),
|
|
a + b[1:] + c[1:])
|
|
self.assertEqual(list(tok.encode(["hello world"])), a)
|
|
|
|
# A Python str is a sequence of one-character strings, so the string overload has
|
|
# to win over the chunk-list one; getting this wrong encodes text letter by letter.
|
|
def test_tokenizer_encode_str_is_not_a_chunk_list(self):
|
|
tok = cv.dnn.Tokenizer.load(_tf("bert/config.json"))
|
|
self.assertEqual(list(tok.encode("hello world")),
|
|
list(tok.encode(["hello world"])))
|
|
|
|
def test_tokenizer_encode_chunks_unsupported(self):
|
|
# Byte-level BPE models declare no pair template to repeat.
|
|
for cfg in ["gpt2/config.json", "gpt4/config.json"]:
|
|
tok = cv.dnn.Tokenizer.load(_tf(cfg))
|
|
with self.assertRaises(cv.error):
|
|
tok.encode(["hello", "world"])
|
|
|
|
def test_tokenizer_encode_chunks_from_pair_template(self):
|
|
# T5 wraps as "A </s> B </s>", Gemma as "<bos> A <bos> B".
|
|
for cfg in ["t5/config.json", "gemma2/config.json"]:
|
|
tok = cv.dnn.Tokenizer.load(_tf(cfg))
|
|
a = list(tok.encode("hello"))
|
|
b = list(tok.encode("world"))
|
|
self.assertEqual(list(tok.encode(["hello", "world"])), a + b)
|
|
|
|
def test_tokenizer_malformed_utf8(self):
|
|
# Malformed sequences resolve to U+FFFD rather than raising, so a single
|
|
# bad byte cannot abort a whole prompt.
|
|
tok = cv.dnn.Tokenizer.load(_tf("t5/config.json"))
|
|
self.assertGreater(len(tok.encode(b"\xff")), 0)
|
|
self.assertGreater(len(tok.encode(b"\xc3")), 0)
|
|
|
|
def test_tokenizer_albert_sequence_normalizer(self):
|
|
# ALBERT wraps with [CLS]=2 / [SEP]=3 and folds case and accents.
|
|
tok = cv.dnn.Tokenizer.load(_tf("albert/config.json"))
|
|
self.assertEqual(list(tok.encode("Hello world")), [2, 10975, 126, 3])
|
|
self.assertEqual(list(tok.encode("café")), [2, 6241, 3])
|
|
self.assertEqual(list(tok.encode("Hello world")), list(tok.encode("hello world")))
|
|
|
|
def test_tokenizer_strip_accents_keeps_non_mark_decompositions(self):
|
|
# Stripping accents must not delete a spacing mark or half a Hangul syllable.
|
|
tok = cv.dnn.Tokenizer.load(_tf("bert/config.json"))
|
|
self.assertEqual(list(tok.encode("हिन्दी")),
|
|
[101, 1339, 29877, 29863, 29861, 29878, 102])
|
|
for text in ("মৌশল", "தமிழ்", "ଓଡ଼ିଆ", "안녕하세요"):
|
|
self.assertEqual(list(tok.encode(text)), [101, 100, 102], text)
|
|
self.assertEqual(list(tok.encode("café")), [101, 7668, 102])
|
|
|
|
def test_with_hf_tiktoken(self):
|
|
tok = cv.dnn.Tokenizer.load(_tf("gpt2/config.json"))
|
|
with open(_tf("gpt2/gpt2_hf_tik_testdata.json"), "r", encoding="utf-8") as f:
|
|
golden = json.load(f)
|
|
|
|
for s in golden["samples"]:
|
|
text = s["text"]
|
|
expected = s["ids"]
|
|
got = tok.encode(text).tolist()
|
|
self.assertEqual(
|
|
got, expected,
|
|
msg=f"Mismatch for sample '{s['name']}'"
|
|
)
|
|
self.assertEqual(tok.decode(expected), text)
|
|
|
|
def test_tokenizer_load_vlm_granite_docling_real_model(self):
|
|
# granite-docling loads through the same generic Tokenizer.load(config.json)
|
|
# entry point as every other model; there is no VLM-specific API.
|
|
tok = cv.dnn.Tokenizer.load(_tf("granite/config.json"))
|
|
ids = tok.encode("hello world")
|
|
self.assertEqual(tok.decode(ids), "hello world")
|
|
|
|
def test_with_hf_tokenizers_load_vlm_granite_docling(self):
|
|
tok = cv.dnn.Tokenizer.load(_tf("granite/config.json"))
|
|
with open(_tf("granite/granite_hf_testdata.json"), "r", encoding="utf-8") as f:
|
|
golden = json.load(f)
|
|
|
|
for s in golden["samples"]:
|
|
text = s["text"]
|
|
expected = s["ids"]
|
|
got = tok.encode(text).tolist()
|
|
self.assertEqual(
|
|
got, expected,
|
|
msg=f"Mismatch for sample '{s['name']}'"
|
|
)
|
|
self.assertEqual(tok.decode(expected), text)
|
|
|
|
def test_tokenizer_load_vlm_paddleocr_vl_real_model(self):
|
|
tok = cv.dnn.Tokenizer.load(_tf("paddleocr_vl/config.json"))
|
|
ids = tok.encode("hello world")
|
|
self.assertEqual(tok.decode(ids), "hello world")
|
|
|
|
def test_with_hf_tokenizers_load_vlm_paddleocr_vl(self):
|
|
tok = cv.dnn.Tokenizer.load(_tf("paddleocr_vl/config.json"))
|
|
with open(_tf("paddleocr_vl/paddleocr_vl_hf_testdata.json"), "r", encoding="utf-8") as f:
|
|
golden = json.load(f)
|
|
|
|
for s in golden["samples"]:
|
|
text = s["text"]
|
|
expected = s["ids"]
|
|
got = tok.encode(text).tolist()
|
|
self.assertEqual(
|
|
got, expected,
|
|
msg=f"Mismatch for sample '{s['name']}'"
|
|
)
|
|
self.assertEqual(tok.decode(expected), text)
|
|
|
|
if __name__ == '__main__':
|
|
NewOpenCVTests.bootstrap()
|