opensportslib 0.2.0.dev3__py3-none-any.whl → 0.2.0.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ from opensportslib.apis import VQAModel
2
+
3
+
4
+ def main():
5
+ """
6
+ Minimal VQA example.
7
+ Update config, question, and dataset paths before running.
8
+ """
9
+
10
+ my_model = VQAModel(
11
+ config="examples/configs/vqa_qwen.yaml",
12
+ weights=None, # optional: path or Hugging Face model ID
13
+ )
14
+
15
+ predictions = my_model.infer(
16
+ test_set="/path/to/test_annotations.json",
17
+ )
18
+
19
+ print(predictions)
20
+
21
+ single_prediction = my_model.infer(
22
+ video_path="/path/to/video.mp4",
23
+ question="What card would you give? Why?",
24
+ )
25
+
26
+ print(single_prediction)
27
+
28
+ metrics = my_model.evaluate(
29
+ test_set="/path/to/test_annotations.json",
30
+ predictions=predictions,
31
+ )
32
+
33
+ print(metrics)
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()
@@ -0,0 +1,172 @@
1
+ TASK: vqa
2
+ VERSION: 2
3
+
4
+ SYSTEM:
5
+ paths:
6
+ save_dir: ./checkpoints_vqa
7
+ work_dir: ${SYSTEM.paths.save_dir}
8
+ device: cuda
9
+ gpu:
10
+ count: 1
11
+ id: 0
12
+ reproducibility:
13
+ use_seed: true
14
+ seed: 42
15
+
16
+ DATA:
17
+ common:
18
+ dataset_name: OSL-XFoul
19
+ data_root: /home/vorajv/dataset/OSL-XFoul
20
+ feature_index: /home/vorajv/dataset/OSL-XFoul/feature_index.json
21
+ prediction_index: /home/vorajv/dataset/OSL-XFoul/prediction_index.json
22
+ runtime:
23
+ loader_backend: opencv
24
+ splits:
25
+ train:
26
+ annotation_path: /home/vorajv/dataset/OSL-XFoul/train.json
27
+ source_path: /home/vorajv/dataset/OSL-XFoul
28
+ dataloader:
29
+ batch_size: 1
30
+ shuffle: true
31
+ num_workers: 0
32
+ pin_memory: false
33
+ mp_context: spawn
34
+ persistent_workers: false
35
+ valid:
36
+ annotation_path: /home/vorajv/dataset/OSL-XFoul/valid.json
37
+ source_path: /home/vorajv/dataset/OSL-XFoul
38
+ dataloader:
39
+ batch_size: 1
40
+ shuffle: false
41
+ num_workers: 0
42
+ pin_memory: false
43
+ mp_context: spawn
44
+ persistent_workers: false
45
+ test:
46
+ annotation_path: /home/vorajv/dataset/OSL-XFoul/test.json
47
+ source_path: /home/vorajv/dataset/OSL-XFoul
48
+ dataloader:
49
+ batch_size: 1
50
+ shuffle: false
51
+ num_workers: 0
52
+ pin_memory: false
53
+ mp_context: spawn
54
+ persistent_workers: false
55
+ inputs:
56
+ video:
57
+ modality: video
58
+ representation: raw
59
+ source:
60
+ format: mp4
61
+ sampling:
62
+ num_frames: 100
63
+ input_fps: 25
64
+ target_fps: 17
65
+ start_frame: 63
66
+ end_frame: 87
67
+ transform: {}
68
+ augmentations: {}
69
+ params: {}
70
+ question:
71
+ modality: text
72
+ representation: raw
73
+ source:
74
+ format: json
75
+ sampling: {}
76
+ transform: {}
77
+ augmentations: {}
78
+ params: {}
79
+
80
+ MODEL:
81
+ runtime:
82
+ dtype: fp16
83
+ device: auto
84
+ compile: false
85
+ freeze: false
86
+ load:
87
+ checkpoint_path: null
88
+ pretrained: false
89
+ strict: true
90
+ map_location: null
91
+ format: auto
92
+ topology:
93
+ - from: video_encoder
94
+ to: mm_projector
95
+ - from: mm_projector
96
+ to: llm_decoder
97
+
98
+ IO:
99
+ inputs:
100
+ video: video_encoder
101
+ question: llm_decoder
102
+ outputs:
103
+ answer_text: llm_decoder
104
+ explanation_text: llm_decoder
105
+
106
+ TRAIN:
107
+ trainer:
108
+ type: vqa
109
+ epochs: 3
110
+ criterion:
111
+ type: CrossEntropyLoss
112
+ optimizer:
113
+ type: AdamW
114
+ lr: 0.0002
115
+ weight_decay: 0.001
116
+ scheduler:
117
+ type: constant
118
+ execution:
119
+ enabled: true
120
+ training_backend: xvars_videochatgpt_lora
121
+ feature_backend: xvars_clip
122
+ view_sampling_policy: random_train_deterministic_eval
123
+ acc_grad_iter: 8
124
+ log_interval: 1
125
+ dry_run: false
126
+ xvars:
127
+ feature_mode: strict_xvars
128
+ projection_path: null
129
+ prompt:
130
+ style: detailed
131
+ include_priors: true
132
+ prediction_prior_adapter: xvars_referee
133
+ prior_fields: [action, offence, contact, bodypart]
134
+ video_token_len: 300
135
+ generation:
136
+ max_new_tokens: 128
137
+ temperature: 0.0
138
+ eval_profile:
139
+ metric_set: [exact_match, contains_match, token_f1, referee_semantic]
140
+ aggregation: mean
141
+ exclusions: []
142
+ sft:
143
+ max_seq_length: 480
144
+ include_video_tokens: true
145
+ disable_tqdm: false
146
+ reference_mode: all
147
+ append_eos_token: true
148
+ gradient_checkpointing: true
149
+ save_strategy: epoch
150
+ lora:
151
+ r: 16
152
+ alpha: 32
153
+ dropout: 0.05
154
+ bias: none
155
+ prepare_kbit: true
156
+ target_modules: [mm_projector, upsample_features, up_proj, down_proj, gate_proj, k_proj, q_proj, v_proj, o_proj]
157
+ exclude_modules: '^base_lm\.model\.mm_projector$'
158
+ quantization:
159
+ enabled: false
160
+ load_in_4bit: true
161
+ bnb_4bit_quant_type: nf4
162
+ compute_dtype: float16
163
+ bnb_4bit_use_double_quant: true
164
+ checkpoint:
165
+ save_adapter: true
166
+ merge_and_save: false
167
+ selection:
168
+ monitor: loss
169
+ mode: min
170
+ checkpoint:
171
+ save_every: 1
172
+ save_best: true
@@ -0,0 +1,58 @@
1
+ SYSTEM:
2
+ paths:
3
+ save_dir: ./checkpoints_vqa_lora
4
+ gpu:
5
+ count: 4
6
+
7
+ MODEL:
8
+ components:
9
+ video_encoder:
10
+ kind: encoder
11
+ source:
12
+ provider: opensportslib
13
+ # XVARS-trained classifier weights to load into that architecture
14
+ load:
15
+ weights_path: /home/vorajv/X-VARS/weights/14_model.pth.tar
16
+ params:
17
+ # CLIP architecture and image processor to instantiate.
18
+ feature_source: indexed_or_raw_clip
19
+ vision_tower: openai/clip-vit-large-patch14
20
+ feature_dim: 1024
21
+ overrides: {}
22
+ mm_projector:
23
+ kind: projector
24
+ source:
25
+ provider: opensportslib
26
+ params:
27
+ input_dim: 1024
28
+ overrides: {}
29
+ llm_decoder:
30
+ kind: decoder
31
+ source:
32
+ provider: opensportslib
33
+ params:
34
+ repo_id: /home/vorajv/X-VARS/weights/base_model_videoChatGPT
35
+ overrides: {}
36
+ metadata:
37
+ backend: xvars_videochatgpt
38
+
39
+ TRAIN:
40
+ execution:
41
+ prompt:
42
+ system_prompt: You are Video-ChatGPT, a large vision-language assistant. You are able to understand the video content that the user provides, and assist the user with a variety of tasks using natural language.Follow the instructions carefully and explain your answers in detail based on the provided video.
43
+
44
+ # Optional XFoul-only generated-answer smoke test.
45
+ # During LoRA training, this runs generation on one known training sample and
46
+ # checks that the answer still contains referee-domain terms and avoids known
47
+ # code-like failure strings. Disable or replace these values for non-XFoul data.
48
+ generated_validation:
49
+ enabled: true
50
+ sample_id: action_0
51
+ every_steps: 25
52
+ max_new_tokens: 128
53
+ require_relevance: true
54
+ required_terms: [foul, card, challenge, spa, dogso, advantage]
55
+ forbidden_terms: [get_children, django, httpclient, "```python", "```php"]
56
+
57
+ hf:
58
+ tokenizer_id: ${MODEL.components.llm_decoder.params.repo_id}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensportslib
3
- Version: 0.2.0.dev3
3
+ Version: 0.2.0.dev4
4
4
  Summary: OpenSportsLib is the professional library, designed for advanced video understanding in sports. It provides state-of-the-art tools for action recognition, spotting, retrieval, and captioning, making it ideal for researchers, analysts, and developers working with sports video data.
5
5
  Author: Jeet Vora
6
6
  Requires-Python: >=3.12
@@ -324,8 +324,8 @@ backend currently supports `Qwen/Qwen2.5-7B-Instruct` and
324
324
  For X-VARS, `feature_source: indexed_or_raw_clip` prefers indexed CLIP features
325
325
  when available and falls back to extracting CLIP features from raw video during
326
326
  `infer()`. Pre-extracted features remain the preferred path for parity, speed,
327
- and reproducibility. See [docs/xvars_integration_phases.md](docs/xvars_integration_phases.md)
328
- for the full X-VARS setup workflow.
327
+ and reproducibility. See [docs/tools/vqa.md](docs/tools/vqa.md) for the full
328
+ VQA setup workflow.
329
329
 
330
330
 
331
331
  ---
@@ -1,5 +1,6 @@
1
1
  examples/quickstart/basic_classification.py,sha256=byefVwdVS6yHuHOSBRHB3czrm5ya4ZpIM_qI72UTpv4,1048
2
2
  examples/quickstart/basic_localization.py,sha256=ZTwVIBFcIuIcctW9OJNHctpsIKAgtVsRzn2HxmYYLaY,1049
3
+ examples/quickstart/basic_vqa.py,sha256=q8qe5X3J3jHatUcykod4LFhvhEYwwXGFXzg0Qd_oO_o,773
3
4
  opensportslib/__init__.py,sha256=yl-0Zd38CgUGwn-ZUpT_rbaJx1nMwUPjod5U1TcAL7U,619
4
5
  opensportslib/cli.py,sha256=45tA92cctPQuruJACAm4TqEbdFwLp-AEknvsOgszxUM,829
5
6
  opensportslib/apis/__init__.py,sha256=4PCdg5owC2HWoTnPYiTl140ddz1rxipexoOmxHKL2yE,460
@@ -17,7 +18,9 @@ opensportslib/configs/localization/default.yaml,sha256=CCsR1ewdVn5aBTo6nBPkBnwCt
17
18
  opensportslib/configs/localization/netvladpp_resnetpca512.yaml,sha256=i-bB-LGekwdha761xuBjIs_6LJpTmQ2MH6Vusc1WSXI,3169
18
19
  opensportslib/configs/localization/video_dali.yaml,sha256=GhbqlNLJNY3U2Vwd1TOBtO--yRNpk0wbt8xFoxTibbg,2802
19
20
  opensportslib/configs/localization/video_ocv.yaml,sha256=sbUpACassaVK6ND4ChogiK9DfMvJisPM9SUpSh8olvg,3055
21
+ opensportslib/configs/vqa/default.yaml,sha256=wH9eOJIwxZVRvXvgyT-0iTIN7IlN29a_GFTIkKB_9mw,4060
20
22
  opensportslib/configs/vqa/qwen.yaml,sha256=yQ7RSCa3JKN3POrgZWjfYdsG_BsQeo0oDekojUe1jqg,1318
23
+ opensportslib/configs/vqa/xvars.yaml,sha256=JA299ps1yQ5upvnX9P-iC7dbvUb4rcBTjGnQQUVRY14,1957
21
24
  opensportslib/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
25
  opensportslib/core/config/__init__.py,sha256=joSTWPBuLQDSFQIbAVpmOyeFLTxhIZRR_-K4DgEK8MM,635
23
26
  opensportslib/core/config/accessors.py,sha256=I6oi2lhqr9HChci90iHV3SnJAw6ykxeTLUVoecS-xhk,19613
@@ -111,8 +114,8 @@ opensportslib/tools/_common.py,sha256=CNSADAK8odNS0xUd2tNExoukrgKEbfNNwGbwIF5JJZ
111
114
  opensportslib/tools/hf_transfer.py,sha256=wkVJc0iyJojJSV6XBAPtPVQ58NbrGNoEZG5c3-bkMEE,31275
112
115
  opensportslib/tools/osl_json_to_parquet.py,sha256=VsEy7KF7x6PA5GPdiAFDgYlyfW9Cey-SvnOiRca2s74,15132
113
116
  opensportslib/tools/parquet_to_osl_json.py,sha256=CcD4oYF8HTwJnANr81PerJXLRqTc6BZ6mVsKu7YNJw0,9259
114
- opensportslib-0.2.0.dev3.dist-info/licenses/LICENSE,sha256=TfPDBt3ar0uv_f9cqCDMZ5rIzW3CY8anRRd4PkL6ejs,34522
115
- opensportslib-0.2.0.dev3.dist-info/licenses/LICENSE-COMMERCIAL,sha256=dg9GjCyCFMcfDJPkZ-IFDDt3Z5x_IdTY3YY-yOC1Eo0,163
117
+ opensportslib-0.2.0.dev4.dist-info/licenses/LICENSE,sha256=TfPDBt3ar0uv_f9cqCDMZ5rIzW3CY8anRRd4PkL6ejs,34522
118
+ opensportslib-0.2.0.dev4.dist-info/licenses/LICENSE-COMMERCIAL,sha256=dg9GjCyCFMcfDJPkZ-IFDDt3Z5x_IdTY3YY-yOC1Eo0,163
116
119
  tests/conftest.py,sha256=M10-WTFqwR8n4GvaHh2uDzeknxOWSwCoWslpHA4VQ3I,26806
117
120
  tests/test_classification_dataset_paths.py,sha256=UMKj52WcWIayPAYVm7ZAmsF2W9yiYecEJ_F7lzmejEs,3636
118
121
  tests/test_classification_trainer_dataloader.py,sha256=T-1ukT7UOjnoAG-9zD4oLHijTRy7qD_vAlMFmavU5oI,5118
@@ -126,9 +129,12 @@ tests/test_localization_dali_filenames.py,sha256=6bUH0UHFXNHsuo93GlYf8vZAjTwnXgw
126
129
  tests/test_package_smoke.py,sha256=-FDsRc4tdeGJwDMXBJT4Dlcsnsx6LvmeUXq9vnFm4YI,666
127
130
  tests/test_pretrained_config_merge_policy.py,sha256=GzCJbxxYfrqTrrUhoWuMVzF5Swb7WmqfruXE0M8UXVo,6677
128
131
  tests/test_public_apis_smoke.py,sha256=SJhAbMPQB-UzvTKPOlA-EoYB6pptJdcRnWRU-YhsOZg,1746
132
+ tests/test_setup_cli.py,sha256=1LFyPZZTsf5xvoGyTME0cZ9-Aog_wnSjDFLIIODVVL4,3157
129
133
  tests/test_subset_train_infer_integration.py,sha256=ACw-PJRCQO2KCd4gjFBU4cgMb2Ka6MRABai_w-0HqJU,8238
130
134
  tests/test_task_model_api_contract.py,sha256=5L5q-eZ2QrL0HhW4EXBihWUevpJ3r3HYbvYuVqLR3A0,32025
135
+ tests/test_vqa_api.py,sha256=7CM8_Is6mDAjldIWVKE8xIVGazclf5R41IJ1s5CG24c,7654
131
136
  tests/test_vqa_metrics_semantic.py,sha256=xAiVRuJi21olgqYcxP3p2ekD-EHqiKzkO-6dZAg2yvo,3973
137
+ tests/test_vqa_qwen_xvars.py,sha256=KpifJb5Gr3my86CQ9VVmxV494Nmz2VVy5t7Tid0JFyU,9805
132
138
  tests/test_vqa_training_lora.py,sha256=AdA-WevoNkdSaikOTuYnqXYyLL-LcPGiZ95AWMyo2EA,20742
133
139
  tests/test_vqa_xvars_videochatgpt.py,sha256=1xCEJRrM0O-CYeQ0GX4rot4WSeEi0eD3w0HTR5LfoPs,50945
134
140
  tools/convert/build_soccernet_gar.py,sha256=XJ9esTiYjFkbhmETgucYa4I9Blb39moxyO74iIh3wBE,42540
@@ -142,8 +148,8 @@ tools/download/download_osl_hf.py,sha256=4dd-Yei2g_lp7NKom8FQlR99PQkQO3DZpAyKmdY
142
148
  tools/download/upload_osl_hf.py,sha256=YO-1uqxs-_6wdEdVYrJ7nqelpRDUOYsEmZW5HhpseiI,5777
143
149
  tools/training/classification.py,sha256=cSMIMNfP08McYUOt17ONo4WVKjAmLDl20WYOidYjJ8k,1277
144
150
  tools/training/localization.py,sha256=UC7liIEF_BrVtN8lnLZ5DOGEqSuwB_rNQgjoN_xEKuI,1271
145
- opensportslib-0.2.0.dev3.dist-info/METADATA,sha256=4xf0tx6TSpzdV9FmI2saESQd7DUmSLqe6ByoC7Fr7Rg,12875
146
- opensportslib-0.2.0.dev3.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
147
- opensportslib-0.2.0.dev3.dist-info/entry_points.txt,sha256=8haQLjVcah3IRKrlsyeecr0cUB5eXH9UPgJ_DDdT4Zk,57
148
- opensportslib-0.2.0.dev3.dist-info/top_level.txt,sha256=IJ6LnztNOeuEKQTiMTPPokBJr9ZxUCEIvbCjlPwUVEs,35
149
- opensportslib-0.2.0.dev3.dist-info/RECORD,,
151
+ opensportslib-0.2.0.dev4.dist-info/METADATA,sha256=fjastTzSf7e0mf-iHBBzfgDYtvLG7zdjNuFLx39VwzE,12842
152
+ opensportslib-0.2.0.dev4.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
153
+ opensportslib-0.2.0.dev4.dist-info/entry_points.txt,sha256=8haQLjVcah3IRKrlsyeecr0cUB5eXH9UPgJ_DDdT4Zk,57
154
+ opensportslib-0.2.0.dev4.dist-info/top_level.txt,sha256=IJ6LnztNOeuEKQTiMTPPokBJr9ZxUCEIvbCjlPwUVEs,35
155
+ opensportslib-0.2.0.dev4.dist-info/RECORD,,
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from opensportslib import cli
4
+ from opensportslib.setup import setup as setup_lib
5
+
6
+
7
+ def test_cli_setup_forwards_xvars_flag(monkeypatch):
8
+ captured: dict[str, object] = {}
9
+
10
+ def fake_setup(*, dali: bool, pyg: bool, vqa_xvars: bool, vqa_qwen: bool):
11
+ captured["dali"] = dali
12
+ captured["pyg"] = pyg
13
+ captured["vqa_xvars"] = vqa_xvars
14
+ captured["vqa_qwen"] = vqa_qwen
15
+
16
+ monkeypatch.setattr(cli, "setup", fake_setup)
17
+
18
+ rc = cli.main(["setup", "--vqa_xvars", "--dali"])
19
+
20
+ assert rc == 0
21
+ assert captured == {"dali": True, "pyg": False, "vqa_xvars": True, "vqa_qwen": False}
22
+
23
+
24
+ def test_cli_setup_forwards_qwen_flag(monkeypatch):
25
+ captured: dict[str, object] = {}
26
+
27
+ def fake_setup(*, dali: bool, pyg: bool, vqa_xvars: bool, vqa_qwen: bool):
28
+ captured["dali"] = dali
29
+ captured["pyg"] = pyg
30
+ captured["vqa_xvars"] = vqa_xvars
31
+ captured["vqa_qwen"] = vqa_qwen
32
+
33
+ monkeypatch.setattr(cli, "setup", fake_setup)
34
+
35
+ rc = cli.main(["setup", "--vqa_qwen", "--pyg"])
36
+
37
+ assert rc == 0
38
+ assert captured == {"dali": False, "pyg": True, "vqa_xvars": False, "vqa_qwen": True}
39
+
40
+
41
+ def test_install_xvars_dependencies_uninstalls_then_reinstalls(monkeypatch):
42
+ calls: list[tuple[str, list[str]]] = []
43
+
44
+ def fake_call(cmd: list[str]):
45
+ calls.append(("call", cmd))
46
+ return 0
47
+
48
+ def fake_check_call(cmd: list[str]):
49
+ calls.append(("check_call", cmd))
50
+ return 0
51
+
52
+ monkeypatch.setattr(setup_lib.subprocess, "call", fake_call)
53
+ monkeypatch.setattr(setup_lib.subprocess, "check_call", fake_check_call)
54
+ monkeypatch.setattr(setup_lib.sys, "executable", "/usr/bin/python3")
55
+
56
+ setup_lib.install_xvars_dependencies(setup_lib.XVARS_DEPENDENCY_PINS)
57
+
58
+ assert calls == [
59
+ (
60
+ "call",
61
+ [
62
+ "/usr/bin/python3",
63
+ "-m",
64
+ "pip",
65
+ "uninstall",
66
+ "-y",
67
+ "transformers",
68
+ "peft",
69
+ "tokenizers",
70
+ "accelerate",
71
+ "trl",
72
+ ],
73
+ ),
74
+ (
75
+ "check_call",
76
+ [
77
+ "/usr/bin/python3",
78
+ "-m",
79
+ "pip",
80
+ "install",
81
+ "transformers==4.38.2",
82
+ "peft==0.9.0",
83
+ "tokenizers==0.15.2",
84
+ "accelerate==0.27.2",
85
+ "trl==0.10.1",
86
+ ],
87
+ ),
88
+ ]
89
+
90
+
91
+ def test_setup_skips_vqa_dependency_install_when_flags_are_false(monkeypatch):
92
+ calls: list[str] = []
93
+
94
+ monkeypatch.setattr(setup_lib, "install_extras", lambda dali=False, pyg=False: calls.append(f"extras:{dali}:{pyg}"))
95
+ monkeypatch.setattr(setup_lib, "install_xvars_dependencies", lambda pins: calls.append(f"deps:{sorted(pins)}"))
96
+ monkeypatch.setattr(setup_lib, "verify", lambda: calls.append("verify"))
97
+ monkeypatch.setattr(setup_lib, "install_torch", lambda: calls.append("torch"))
98
+
99
+ setup_lib.setup(dali=True, pyg=False, vqa_xvars=False, vqa_qwen=False)
100
+
101
+ assert calls == ["torch", "extras:True:False", "verify"]
tests/test_vqa_api.py ADDED
@@ -0,0 +1,240 @@
1
+ from pathlib import Path
2
+ from types import SimpleNamespace
3
+ import inspect
4
+ import json
5
+ import sys
6
+
7
+ import pytest
8
+
9
+ from opensportslib.apis import VQAModel
10
+ from opensportslib.apis._xvars_runtime import (
11
+ XVarsHeadlessRuntime,
12
+ XVarsPrediction,
13
+ _dependency_error,
14
+ build_xvars_prompt,
15
+ )
16
+
17
+
18
+ class _FakeRuntime:
19
+ def __init__(self):
20
+ self.model = object()
21
+ self.image_processor = object()
22
+ self.calls = []
23
+
24
+ def infer_one(self, *, video_path, question, conv_mode, temperature, max_new_tokens):
25
+ self.calls.append(
26
+ {
27
+ "video_path": video_path,
28
+ "question": question,
29
+ "conv_mode": conv_mode,
30
+ "temperature": temperature,
31
+ "max_new_tokens": max_new_tokens,
32
+ }
33
+ )
34
+ return XVarsPrediction(
35
+ answer_text=f"answer::{question}",
36
+ action_class="Tackling",
37
+ offence_class="Offence",
38
+ severity_class="3.0",
39
+ )
40
+
41
+
42
+ def test_vqa_method_signatures_expose_weights(vqa_config_path):
43
+ api = VQAModel(config=vqa_config_path)
44
+
45
+ for method_name in ("load_weights", "train", "infer", "evaluate"):
46
+ sig = inspect.signature(getattr(api, method_name))
47
+ assert "weights" in sig.parameters
48
+ assert any(
49
+ p.kind == inspect.Parameter.VAR_KEYWORD
50
+ for p in sig.parameters.values()
51
+ )
52
+
53
+ infer_sig = inspect.signature(api.infer)
54
+ assert "test_set" in infer_sig.parameters
55
+
56
+
57
+ def test_vqa_constructor_weights_are_default_for_infer(vqa_config_path, monkeypatch):
58
+ calls = []
59
+ runtime = _FakeRuntime()
60
+
61
+ def fake_get_runtime(self, weights=None):
62
+ calls.append(weights)
63
+ self.last_loaded_weights = weights
64
+ self.best_checkpoint = weights
65
+ return runtime
66
+
67
+ monkeypatch.setattr(VQAModel, "_get_runtime", fake_get_runtime)
68
+ api = VQAModel(config=vqa_config_path, weights="default-adapter")
69
+
70
+ predictions = api.infer(
71
+ video_path="/tmp/sample.mp4",
72
+ question="Is it a foul or not? Why?",
73
+ use_wandb=False,
74
+ )
75
+
76
+ assert predictions["task"] == "vqa"
77
+ assert predictions["data"][0]["answer_text"] == "answer::Is it a foul or not? Why?"
78
+ assert calls[-1] == "default-adapter"
79
+
80
+ api.infer(
81
+ video_path="/tmp/sample.mp4",
82
+ question="What card would you give? Why?",
83
+ weights="override-adapter",
84
+ use_wandb=False,
85
+ )
86
+ assert calls[-1] == "override-adapter"
87
+
88
+
89
+ def test_vqa_single_video_multi_question_infer(vqa_config_path, monkeypatch):
90
+ runtime = _FakeRuntime()
91
+ monkeypatch.setattr(VQAModel, "_get_runtime", lambda self, weights=None: runtime)
92
+
93
+ api = VQAModel(config=vqa_config_path)
94
+ predictions = api.infer(
95
+ video_path="/tmp/clip_0.mp4",
96
+ questions=[
97
+ "Is it a foul or not? Why?",
98
+ "What card would you give? Why?",
99
+ ],
100
+ use_wandb=False,
101
+ )
102
+
103
+ assert predictions["task"] == "vqa"
104
+ assert [row["id"] for row in predictions["data"]] == ["clip_0:0", "clip_0:1"]
105
+ assert [row["question"] for row in predictions["data"]] == [
106
+ "Is it a foul or not? Why?",
107
+ "What card would you give? Why?",
108
+ ]
109
+ assert predictions["data"][0]["video_path"] == "/tmp/clip_0.mp4"
110
+
111
+
112
+ def test_vqa_dataset_infer_expands_all_questions(vqa_config_path, tmp_path, monkeypatch):
113
+ runtime = _FakeRuntime()
114
+ monkeypatch.setattr(VQAModel, "_get_runtime", lambda self, weights=None: runtime)
115
+
116
+ dataset_path = tmp_path / "test.json"
117
+ dataset_path.write_text(
118
+ json.dumps(
119
+ {
120
+ "data": [
121
+ {
122
+ "id": "action_0",
123
+ "video_path": "/tmp/action_0.mp4",
124
+ "questions": [
125
+ "Is it a foul or not? Why?",
126
+ "What card would you give? Why?",
127
+ ],
128
+ },
129
+ {
130
+ "id": "action_1",
131
+ "inputs": [{"type": "video", "path": "relative/action_1.mp4"}],
132
+ "question": "Could the referee have given advantage? Why?",
133
+ },
134
+ ]
135
+ }
136
+ ),
137
+ encoding="utf-8",
138
+ )
139
+
140
+ api = VQAModel(config=vqa_config_path)
141
+ predictions = api.infer(test_set=str(dataset_path), use_wandb=False)
142
+
143
+ assert predictions["task"] == "vqa"
144
+ assert len(predictions["data"]) == 3
145
+ assert [row["id"] for row in predictions["data"]] == [
146
+ "action_0:0",
147
+ "action_0:1",
148
+ "action_1",
149
+ ]
150
+ assert predictions["data"][2]["video_path"] == str(
151
+ (dataset_path.parent / "relative/action_1.mp4").resolve()
152
+ )
153
+
154
+
155
+ def test_build_xvars_prompt_injects_classifier_priors():
156
+ class FakeState:
157
+ def __init__(self):
158
+ self.roles = ("USER", "ASSISTANT")
159
+ self.messages = []
160
+ self.sep = " "
161
+ self.sep2 = "</s>"
162
+ self.sep_style = SimpleNamespace(name="TWO")
163
+ self.action_prompt = ""
164
+ self.offence_prompt = ""
165
+
166
+ def copy(self):
167
+ return FakeState()
168
+
169
+ def append_message(self, role, message):
170
+ self.messages.append([role, message])
171
+
172
+ def set_predictions(self, action_class, offence_class):
173
+ self.action_prompt = action_class
174
+ self.offence_prompt = offence_class
175
+
176
+ def get_prompt(self):
177
+ return (
178
+ f"{self.messages[0][0]}:{self.messages[0][1]}"
179
+ f"|priors:{self.action_prompt}{self.offence_prompt}"
180
+ )
181
+
182
+ prompt, stop_str = build_xvars_prompt(
183
+ conv_templates={"video-chatgpt_v1": FakeState()},
184
+ conv_mode="video-chatgpt_v1",
185
+ question="What card would you give? Why?",
186
+ replace_token="<vid_tokens>",
187
+ video_token="<video>",
188
+ action_prompt="a tackle",
189
+ offence_prompt=", foul and a yellow card",
190
+ )
191
+
192
+ assert "<vid_tokens>" in prompt
193
+ assert "a tackle, foul and a yellow card" in prompt
194
+ assert "<video>" not in prompt
195
+ assert stop_str == "</s>"
196
+
197
+
198
+ def test_vqa_save_predictions_round_trip(vqa_config_path, tmp_path, monkeypatch):
199
+ runtime = _FakeRuntime()
200
+ monkeypatch.setattr(VQAModel, "_get_runtime", lambda self, weights=None: runtime)
201
+
202
+ api = VQAModel(config=vqa_config_path)
203
+ predictions = api.infer(
204
+ video_path="/tmp/clip_0.mp4",
205
+ question="Is it a foul or not? Why?",
206
+ use_wandb=False,
207
+ )
208
+
209
+ out_path = tmp_path / "predictions.json"
210
+ saved = api.save_predictions(str(out_path), predictions)
211
+
212
+ assert saved == str(out_path)
213
+ assert json.loads(out_path.read_text(encoding="utf-8"))["task"] == "vqa"
214
+
215
+
216
+ def test_vqa_train_and_evaluate_raise_not_implemented(vqa_config_path):
217
+ api = VQAModel(config=vqa_config_path)
218
+
219
+ with pytest.raises(NotImplementedError):
220
+ api.train(use_wandb=False)
221
+
222
+ with pytest.raises(NotImplementedError):
223
+ api.evaluate(use_wandb=False)
224
+
225
+
226
+ def test_vqa_import_does_not_pull_gradio_or_demo_modules():
227
+ assert "gradio" not in sys.modules
228
+ assert "video_chatgpt.demo.chat" not in sys.modules
229
+ assert "video_chatgpt.demo" not in sys.modules
230
+
231
+
232
+ def test_vqa_runtime_dependency_error_names_missing_package(monkeypatch):
233
+ runtime = XVarsHeadlessRuntime()
234
+
235
+ def fail_bootstrap():
236
+ raise ModuleNotFoundError("No module named 'numpy'", name="numpy")
237
+
238
+ monkeypatch.setattr(runtime, "_bootstrap_components", fail_bootstrap)
239
+ error = _dependency_error(ModuleNotFoundError("No module named 'numpy'", name="numpy"))
240
+ assert "numpy" in str(error)
@@ -0,0 +1,251 @@
1
+ from types import SimpleNamespace
2
+
3
+ import torch
4
+
5
+ from opensportslib.models.base.qwen_xvars import QwenXVarsCausalLM, QwenXVarsModel
6
+ from opensportslib.models.builder import build_model
7
+
8
+
9
+ class TinyTokenizer:
10
+ pad_token_id = 0
11
+ eos_token_id = 1
12
+ pad_token = "<pad>"
13
+ eos_token = "</s>"
14
+
15
+ def __init__(self):
16
+ self.vocab = {"<pad>": 0, "</s>": 1, "<vid_patch>": 2, "<vid_start>": 3, "<vid_end>": 4}
17
+
18
+ def get_vocab(self):
19
+ return dict(self.vocab)
20
+
21
+ def add_special_tokens(self, payload):
22
+ added = 0
23
+ for token in payload.get("additional_special_tokens", []):
24
+ if token not in self.vocab:
25
+ self.vocab[token] = len(self.vocab)
26
+ added += 1
27
+ return added
28
+
29
+ def convert_tokens_to_ids(self, token):
30
+ return self.vocab[token]
31
+
32
+ def __len__(self):
33
+ return len(self.vocab)
34
+
35
+ def __call__(self, text, return_tensors=None, **kwargs):
36
+ del kwargs
37
+ if isinstance(text, list):
38
+ text = text[0]
39
+ tokens = []
40
+ i = 0
41
+ while i < len(text):
42
+ matched = False
43
+ for token in ("<vid_start>", "<vid_patch>", "<vid_end>", "</s>"):
44
+ if text.startswith(token, i):
45
+ tokens.append(token)
46
+ i += len(token)
47
+ matched = True
48
+ break
49
+ if matched:
50
+ continue
51
+ if text[i].isspace():
52
+ i += 1
53
+ continue
54
+ j = i
55
+ while j < len(text) and not text[j].isspace() and not any(
56
+ text.startswith(token, j) for token in ("<vid_start>", "<vid_patch>", "<vid_end>", "</s>")
57
+ ):
58
+ j += 1
59
+ tokens.append(text[i:j])
60
+ i = j
61
+ ids = []
62
+ for token in tokens:
63
+ if token not in self.vocab:
64
+ self.vocab[token] = len(self.vocab)
65
+ ids.append(self.vocab[token])
66
+ attn = [1] * len(ids)
67
+ if return_tensors == "pt":
68
+ return {"input_ids": torch.tensor([ids]), "attention_mask": torch.tensor([attn])}
69
+ return SimpleNamespace(input_ids=ids, attention_mask=attn)
70
+
71
+ def batch_decode(self, sequences, skip_special_tokens=True):
72
+ del sequences, skip_special_tokens
73
+ return ["qwen answer </s>"]
74
+
75
+
76
+ class TinyGenerateLM(torch.nn.Module):
77
+ def __init__(self):
78
+ super().__init__()
79
+ self.config = SimpleNamespace(hidden_size=4, vocab_size=64, use_cache=True)
80
+ self.generation_config = SimpleNamespace(use_cache=False)
81
+ self.emb = torch.nn.Embedding(128, 4)
82
+ self.lm = torch.nn.Linear(4, 64)
83
+ self.seen_inputs_embeds = None
84
+ self.generate_kwargs = None
85
+
86
+ def get_input_embeddings(self):
87
+ return self.emb
88
+
89
+ def resize_token_embeddings(self, size):
90
+ return self.emb
91
+
92
+ def forward(self, input_ids=None, inputs_embeds=None, attention_mask=None, labels=None, **kwargs):
93
+ del input_ids, attention_mask, kwargs
94
+ self.seen_inputs_embeds = inputs_embeds.detach().clone()
95
+ logits = self.lm(inputs_embeds)
96
+ loss = logits.sum() * 0
97
+ if labels is not None:
98
+ loss = loss + 0.123
99
+ return SimpleNamespace(loss=loss, logits=logits)
100
+
101
+ def generate(self, input_ids=None, inputs_embeds=None, attention_mask=None, **kwargs):
102
+ del attention_mask
103
+ self.generate_kwargs = {"input_ids": input_ids, "inputs_embeds": inputs_embeds, **kwargs}
104
+ extra = torch.tensor([[9, 1]], device=input_ids.device if input_ids is not None else inputs_embeds.device)
105
+ if input_ids is None:
106
+ prefix = torch.zeros((1, inputs_embeds.shape[1]), dtype=torch.long, device=extra.device)
107
+ return torch.cat((prefix, extra), dim=1)
108
+ return torch.cat((input_ids, extra), dim=1)
109
+
110
+
111
+ def _cfg(tmp_path):
112
+ return SimpleNamespace(
113
+ TASK="VQA",
114
+ SYSTEM=SimpleNamespace(device="cpu", gpu=SimpleNamespace(count=0, id=0)),
115
+ MODEL=SimpleNamespace(
116
+ runtime=SimpleNamespace(dtype="fp32"),
117
+ load=SimpleNamespace(checkpoint_path=None),
118
+ components=SimpleNamespace(
119
+ video_encoder=SimpleNamespace(
120
+ kind="encoder",
121
+ source=SimpleNamespace(provider="opensportslib", name="xvars_clip_features"),
122
+ load=SimpleNamespace(weights_path=str(tmp_path / "vision.pt")),
123
+ params=SimpleNamespace(feature_source="indexed_or_raw_clip", vision_tower="clip", feature_dim=1024),
124
+ overrides=SimpleNamespace(),
125
+ ),
126
+ mm_projector=SimpleNamespace(
127
+ kind="projector",
128
+ source=SimpleNamespace(provider="opensportslib"),
129
+ params=SimpleNamespace(input_dim=1024),
130
+ overrides=SimpleNamespace(),
131
+ ),
132
+ llm_decoder=SimpleNamespace(
133
+ kind="decoder",
134
+ source=SimpleNamespace(provider="huggingface", name="Qwen/Qwen3.5-9B-Base"),
135
+ params=SimpleNamespace(repo_id="Qwen/Qwen3.5-9B-Base"),
136
+ overrides=SimpleNamespace(),
137
+ ),
138
+ ),
139
+ metadata=SimpleNamespace(backend="qwen_xvars_infer"),
140
+ ),
141
+ DATA=SimpleNamespace(
142
+ inputs=SimpleNamespace(video=SimpleNamespace(sampling=SimpleNamespace(num_frames=100, start_frame=63, end_frame=87, input_fps=25, target_fps=17))),
143
+ ),
144
+ TRAIN=SimpleNamespace(
145
+ execution={
146
+ "prompt": {"include_priors": True, "video_token_len": 3},
147
+ "generation": {"max_new_tokens": 8, "temperature": 0.0},
148
+ "xvars": {"feature_mode": "strict_xvars"},
149
+ "hf": {"local_files_only": True, "prefer_cuda": False, "tokenizer_id": "Qwen/Qwen3.5-9B-Base"},
150
+ "quantization": {"enabled": False},
151
+ }
152
+ ),
153
+ )
154
+
155
+
156
+ def test_qwen_xvars_wrapper_projects_features_into_hidden_size():
157
+ tokenizer = TinyTokenizer()
158
+ base = TinyGenerateLM()
159
+ model = QwenXVarsCausalLM(base, mm_hidden_size=1024)
160
+ input_ids = torch.tensor([[tokenizer.convert_tokens_to_ids("<vid_start>"), tokenizer.convert_tokens_to_ids("<vid_patch>"), tokenizer.convert_tokens_to_ids("<vid_patch>"), tokenizer.convert_tokens_to_ids("<vid_end>")]])
161
+ features = torch.ones((1, 2, 1024))
162
+
163
+ out = model(
164
+ input_ids=input_ids,
165
+ attention_mask=torch.ones_like(input_ids),
166
+ labels=input_ids.clone(),
167
+ video_spatio_temporal_features=features,
168
+ tokenizer=tokenizer,
169
+ )
170
+
171
+ assert abs(out.loss.item() - 0.123) < 1e-6
172
+ assert model.mm_projector.out_features == base.config.hidden_size
173
+ assert tuple(base.seen_inputs_embeds.shape) == (1, 4, base.config.hidden_size)
174
+
175
+
176
+ def test_qwen_xvars_model_generate_answer_uses_existing_feature_contract(tmp_path, monkeypatch):
177
+ cfg = _cfg(tmp_path)
178
+ tokenizer = TinyTokenizer()
179
+ base_model = TinyGenerateLM()
180
+
181
+ from transformers import AutoModelForCausalLM, AutoTokenizer
182
+
183
+ monkeypatch.setattr(AutoTokenizer, "from_pretrained", lambda *args, **kwargs: tokenizer)
184
+ monkeypatch.setattr(AutoModelForCausalLM, "from_pretrained", lambda *args, **kwargs: base_model)
185
+
186
+ model = QwenXVarsModel(cfg, model_id="Qwen/Qwen3.5-9B-Base", projector_params={"input_dim": 1024})
187
+ answer = model.generate_answer(
188
+ {
189
+ "id": "action_0",
190
+ "question": "Was this a foul?",
191
+ "references": [],
192
+ "labels": {},
193
+ "metadata": {},
194
+ "prior_prediction_text": "",
195
+ "video_spatio_temporal_features": torch.ones((3, 1024)),
196
+ },
197
+ prompt_cfg={"video_token_len": 3},
198
+ generation_cfg={"max_new_tokens": 8, "temperature": 0.0},
199
+ )
200
+
201
+ assert model._ready is True
202
+ assert answer == "qwen answer"
203
+ assert base_model.generate_kwargs is not None
204
+ assert base_model.generate_kwargs["inputs_embeds"].shape[-1] == base_model.config.hidden_size
205
+ assert base_model.generate_kwargs["inputs_embeds"].shape[-2] == base_model.generate_kwargs["input_ids"].shape[-1]
206
+
207
+
208
+ def test_qwen_loader_does_not_pass_use_cache_to_from_pretrained(tmp_path, monkeypatch):
209
+ cfg = _cfg(tmp_path)
210
+ tokenizer = TinyTokenizer()
211
+ base_model = TinyGenerateLM()
212
+ captured = {}
213
+
214
+ from transformers import AutoModelForCausalLM, AutoTokenizer
215
+
216
+ def _load_tokenizer(*args, **kwargs):
217
+ del args, kwargs
218
+ return tokenizer
219
+
220
+ def _load_model(model_id, **kwargs):
221
+ captured["model_id"] = model_id
222
+ captured["model_kwargs"] = dict(kwargs)
223
+ if "use_cache" in kwargs:
224
+ raise TypeError("unexpected load kwarg")
225
+ return base_model
226
+
227
+ monkeypatch.setattr(AutoTokenizer, "from_pretrained", _load_tokenizer)
228
+ monkeypatch.setattr(AutoModelForCausalLM, "from_pretrained", _load_model)
229
+
230
+ model = QwenXVarsModel(cfg, model_id="Qwen/Qwen3.5-9B-Base", projector_params={"input_dim": 1024})
231
+
232
+ assert model._ready is True
233
+ assert captured["model_id"] == "Qwen/Qwen3.5-9B-Base"
234
+ assert "use_cache" not in captured["model_kwargs"]
235
+ assert base_model.config.use_cache is True
236
+ assert base_model.generation_config.use_cache is True
237
+
238
+
239
+ def test_builder_routes_qwen_backend(tmp_path, monkeypatch):
240
+ cfg = _cfg(tmp_path)
241
+ tokenizer = TinyTokenizer()
242
+ base_model = TinyGenerateLM()
243
+
244
+ from transformers import AutoModelForCausalLM, AutoTokenizer
245
+
246
+ monkeypatch.setattr(AutoTokenizer, "from_pretrained", lambda *args, **kwargs: tokenizer)
247
+ monkeypatch.setattr(AutoModelForCausalLM, "from_pretrained", lambda *args, **kwargs: base_model)
248
+
249
+ model, _ = build_model(cfg, device="cpu")
250
+
251
+ assert isinstance(model, QwenXVarsModel)