Files
opencv/modules/python/test/test_tokenizer.py
T
Jaivardhan Bhola 84c2360c27 Merge pull request #29675 from jaivardhan-bhola:generalized-tokenizer
Generalize tokenizer loading to support method-based family dispatch in DNN - #29675

Companion PR: https://github.com/opencv/opencv_extra/pull/1402

### Changes
Added ALBERT and BERT support end-to-end, with `samples/dnn/albert_inference.py `and `samples/dnn/bert_inference.py `as validation samples, plus expanded coverage in modules/dnn/test/test_tokenizer.cpp. Required changes to `cv::dnn::dnn.hpp`, `graph_fusion_attention.cpp`, and `unicode.cpp/unicode.hpp` to support Unigram and WordPiece tokenizers.

To back ALBERT/BERT, generalized `cv::dnn::Tokenizer `from a single BPE implementation into a method-dispatched frontend, adding `core_wordpiece.cpp/hpp` (WordPiece) and `core_unigram.cpp/hpp` (Unigram) as new backends. `tokenizer.cpp` now routes by method across BPE, Gemma,, SentencePiece, Unigram, and WordPiece behind one shared interface.

Tested against the following samples and the output matches to old tokenizer:

```
gpt2_inference.py
qwen_inference.py
gemma3_inference.py
```
GPT2:

```
Preparing GPT-2 model...
Inferencing GPT-2 model...
Hello, I'm a language model, not a programming language. I'm a language model. I'm a language model. I'm a language model. I'm a language model. I'm a
```

Gemma3:
```
Preparing Gemma3 model...
Prompt:
<start_of_turn>user
What is OpenCV?<end_of_turn>
<start_of_turn>model

Inferencing Gemma3 model...
Response:
Okay, let's break down what OpenCV is.

**What is OpenCV?**

OpenCV (Open Source Computer Vision Library) is a powerful and
```

Qwen2.5:
```
Preparing Qwen2.5 model...
Prompt:
<|im_start|>user
What is OpenCV?<|im_end|>
<|im_start|>assistant

Inferencing Qwen2.5 model...
Response:
OpenCV is a set of computer vision libraries in C++ designed to be used for image and video processing. It provides a wide range of tools and functions for
```

### Tokenizer References 
Byte-level BPE: [tokenizers/src/pre_tokenizers/byte_level.rs](https://github.com/huggingface/tokenizers/blob/main/tokenizers/src/pre_tokenizers/byte_level.rs)
(This defines the byte-level mapping rules, which is used in conjunction with the [BPE model](https://www.google.com/search?q=https://github.com/huggingface/tokenizers/blob/main/tokenizers/src/models/bpe/mod.rs))

SentencePiece BPE (Metaspace): [tokenizers/src/pre_tokenizers/metaspace.rs](https://www.google.com/search?q=https://github.com/huggingface/tokenizers/blob/main/tokenizers/src/pre_tokenizers/metaspace.rs)
(This defines the rule for replacing whitespace with the U+2581 _ character and handling byte fallback)

Unigram: [tokenizers/src/models/unigram/mod.rs](https://www.google.com/search?q=https://github.com/huggingface/tokenizers/blob/main/tokenizers/src/models/unigram/mod.rs)
(This contains the core logic for the Unigram lattice scoring and probabilistic tokenization rules)

WordPiece: [tokenizers/src/models/wordpiece/mod.rs](https://www.google.com/search?q=https://github.com/huggingface/tokenizers/blob/main/tokenizers/src/models/wordpiece/mod.rs)
(This explicitly cites Schuster & Nakajima in the code comments and implements the greedy longest-match rule with the ## prefix)

### Pull Request Readiness Checklist

- [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
      code under GPL or another license incompatible with OpenCV.
- [x] The PR is proposed to the proper branch (`5.x`).
- [x] There is a reference to the original bug report and related work.
- [x] There is accuracy test and test data in `opencv_extra`, same
      branch name (`generalized-tokenizer`) — `bert/`, `t5/` fixtures
      back the new C++ tests.
- [x] The feature is documented and sample code builds with project CMake.
2026-09-23 16:20:33 +03:00

126 lines
5.2 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)
if __name__ == '__main__':
NewOpenCVTests.bootstrap()