mirror of
https://github.com/opencv/opencv.git
synced 2026-09-25 04:09:57 +03:00
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.
126 lines
5.2 KiB
Python
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()
|