1078 lines
56 KiB
Python
1078 lines
56 KiB
Python
"""CPU tests for B1 alignment, causal pooling, fusion, and train/infer round trips."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image, ImageDraw
|
|
|
|
from easycom_head import (
|
|
LOCAL_FUSION_SCOPES,
|
|
TurnPredictionHead,
|
|
checkpoint_head_config,
|
|
)
|
|
from extract_turn_local_features import (
|
|
FrozenQwenLocalEncoder, ego4d_clip_boxes, extract_local,
|
|
validate_source_timeline,
|
|
model_config_digest,
|
|
box_coordinate_offset, frame_crops, parse_args, preview_boxes, preview_ticks, preview_time_bounds,
|
|
TESTED_TRANSFORMERS_VERSIONS, validate_local_transformers_version,
|
|
main as extract_main,
|
|
)
|
|
from local_turn_features import (
|
|
LOCAL_FORMAT, FramePersonFeatures, causal_frame_ticks, crop_box, easycom_boxes,
|
|
load_frame_set_features, load_local_features, load_track_features,
|
|
local_head_dimensions,
|
|
pooled_recent_visual, reference_alignment,
|
|
)
|
|
from train_turn_head import _load_examples
|
|
|
|
|
|
def fixture(n=20, name="Session_4", dataset="easycom"):
|
|
times = (torch.arange(n, dtype=torch.float64) + 1) / 5
|
|
reference = {"states": torch.randn(n, 4, 8), "label_indices": torch.arange(n),
|
|
"times": times, "mode": "open-stream-replay", "start": 0}
|
|
side = {"format": LOCAL_FORMAT, "dataset": dataset, "sample_id": name,
|
|
"times": times.clone(), "label_indices": torch.arange(n), "partial": False,
|
|
"visual": torch.randn(n, 6), "audio": torch.randn(n, 5),
|
|
"visual_valid": torch.ones(n, dtype=torch.bool),
|
|
"audio_valid": torch.ones(n, dtype=torch.bool),
|
|
"config": {"visual_dim": 6, "audio_dim": 5, "encoder_id": "synthetic-test",
|
|
"causal_policy": "half-open-trailing-window", "uses_speech_labels": False}}
|
|
side["visual_valid"][0] = False
|
|
side["visual"][0] = 0
|
|
frame_times = torch.arange(max(1, (n + 2) // 3), dtype=torch.float64) * .5
|
|
counts = [2 if index % 3 == 0 else (0 if index % 3 == 1 else 1)
|
|
for index in range(len(frame_times))]
|
|
offsets = torch.tensor([0, *np.cumsum(counts).tolist()], dtype=torch.long)
|
|
person_features = torch.randn(int(offsets[-1]), 6)
|
|
side["persons"] = {
|
|
"features": person_features,
|
|
"frame_times": frame_times,
|
|
"frame_offsets": offsets,
|
|
"records": [{"id": str(index)} for index in range(len(person_features))],
|
|
}
|
|
labels = np.tile(np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 1]], dtype=np.float32), (n // 4 + 1, 1))[:n]
|
|
return reference, side, labels
|
|
|
|
|
|
class LocalTests(unittest.TestCase):
|
|
def setUp(self):
|
|
torch.set_num_threads(1)
|
|
|
|
def test_tested_transformers_versions(self):
|
|
for version in ("4.52.3", "4.57.6"):
|
|
self.assertEqual(validate_local_transformers_version(version), version)
|
|
for version in ("4.50.0.dev0", "4.57.6.dev0", "5.0.0"):
|
|
with self.subTest(version=version), self.assertRaisesRegex(RuntimeError, "found " + version):
|
|
validate_local_transformers_version(version)
|
|
|
|
def test_half_open_grid_and_pooling(self):
|
|
self.assertEqual(list(causal_frame_ticks(.2, 2)), [0])
|
|
self.assertEqual(list(causal_frame_ticks(1., 2)), [0, 1])
|
|
ts = torch.tensor([0., .5, 1., 1.5], dtype=torch.float64)
|
|
vectors = torch.tensor([[2.], [4.], [90.], [100.]])
|
|
valid = torch.ones(4, dtype=torch.bool)
|
|
value, present = pooled_recent_visual(ts, vectors, valid, 1., 1.)
|
|
self.assertTrue(present)
|
|
self.assertEqual(value.item(), 3.) # no frame at t=1
|
|
value, _ = pooled_recent_visual(ts, vectors, valid, 1.2, 1.)
|
|
self.assertEqual(value.item(), 47.) # .5 and 1.0, not 0.0
|
|
value, present = pooled_recent_visual(ts, vectors, valid * False, 1., 1.)
|
|
self.assertFalse(present)
|
|
self.assertEqual(value.item(), 0.)
|
|
|
|
def test_box_formats_and_crop(self):
|
|
person = {"Participant_ID": -1, "x1": 2, "y1": 3, "x2": 7, "y2": 9}
|
|
faces = easycom_boxes([{"Frame_Number": 1, "Participants": [person]}], "face")
|
|
heads = easycom_boxes([{"Frame_Number": 0, "Participants": person}], "head")
|
|
self.assertEqual(faces, heads)
|
|
self.assertEqual(crop_box([202., 3., 207., 9.], 1920, 1080, offset_x=200), (2, 3, 7, 9))
|
|
self.assertIsNone(crop_box([4., 4., 3., 5.], 10, 10))
|
|
self.assertIsNone(crop_box([float("nan"), 4., 9., 5.], 10, 10))
|
|
|
|
def test_ego4d_clip_frames(self):
|
|
boxes = [{"frame": 3, "clip_frame": None, "x": 1, "y": 2, "width": 3, "height": 4}]
|
|
person = {"person_id": "x", "camera_wearer": False, "tracking_paths": [{"track": boxes}, {"track": boxes}]}
|
|
wearer = {**person, "camera_wearer": True}
|
|
actual = ego4d_clip_boxes({"persons": [wearer, person]})
|
|
self.assertEqual(actual, {3: [{"id": "x", "xyxy": [1., 2., 4., 6.]}]})
|
|
|
|
def test_dataset_specific_coordinate_defaults_and_explicit_overrides(self):
|
|
required = ["--data-root", "/raw", "--feature-root", "/global",
|
|
"--target-root", "/targets", "--output-root", "/local", "--samples", "4"]
|
|
self.assertEqual(parse_args(required).box_coordinate_space, "encoded")
|
|
self.assertEqual(parse_args(required + ["--dataset", "ego4d"]).box_coordinate_space, "camera")
|
|
self.assertEqual(parse_args(required + ["--box-coordinate-space", "camera"]).box_coordinate_space, "camera")
|
|
# An appended correction overrides an old LOCAL_ARGS array in the shell.
|
|
self.assertEqual(parse_args(required + ["--box-coordinate-space", "camera",
|
|
"--box-coordinate-space", "encoded"]).box_coordinate_space, "encoded")
|
|
|
|
def test_frame_crops_subtract_each_clips_banner_before_encoding(self):
|
|
expected = (100, 80, 160, 140)
|
|
image = Image.new("RGB", (1920, 1080))
|
|
ImageDraw.Draw(image).rectangle(expected, fill="red")
|
|
clips, annotations = [], []
|
|
for width in (2123, 2124, 2203, 1920):
|
|
offset = width - 1920
|
|
clips.append(SimpleNamespace(crop=(offset, 0, 1920, 1080), dense_start=float(len(clips))))
|
|
annotations.append({0: [{"id": "3", "xyxy": [100 + offset, 80, 160 + offset, 140]}]})
|
|
media = SimpleNamespace(clips=clips,
|
|
_source_frame_for_time=lambda t: (int(t), 0, 0.),
|
|
frame_at_or_before=lambda t: image.copy())
|
|
args = SimpleNamespace(box_coordinate_space="encoded", crop_expansion=1.)
|
|
for index, clip in enumerate(clips):
|
|
with self.subTest(crop=clip.crop):
|
|
_, crops, records = frame_crops(media, annotations, float(index), args)
|
|
self.assertEqual(records[0]["box"], expected)
|
|
self.assertEqual(records[0]["coordinate_offset"], [clip.crop[0], 0])
|
|
self.assertEqual(records[0]["annotation_box"], annotations[index][0][0]["xyxy"])
|
|
self.assertEqual(crops[0].size, (60, 60))
|
|
self.assertEqual(crops[0].getpixel((30, 30)), (255, 0, 0))
|
|
|
|
def test_cropped_camera_annotations_and_uncropped_ego4d_are_not_shifted_twice(self):
|
|
clip = SimpleNamespace(crop=(204, 12, 1920, 1080), dense_start=0.)
|
|
self.assertEqual(box_coordinate_offset(clip, "encoded"), (204, 12))
|
|
self.assertEqual(box_coordinate_offset(clip, "camera"), (0, 0))
|
|
self.assertEqual(box_coordinate_offset(SimpleNamespace(crop=None), "encoded"), (0, 0))
|
|
with self.assertRaisesRegex(ValueError, "coordinate space"):
|
|
box_coordinate_offset(clip, "typo")
|
|
media = SimpleNamespace(clips=[clip], _source_frame_for_time=lambda t: (0, 0, 0.),
|
|
frame_at_or_before=lambda t: Image.new("RGB", (1920, 1080)))
|
|
annotations = [{0: [{"id": "3", "xyxy": [100, 80, 160, 140]}]}]
|
|
args = SimpleNamespace(box_coordinate_space="camera", crop_expansion=1.)
|
|
_, _, records = frame_crops(media, annotations, .1, args)
|
|
self.assertEqual(records[0]["box"], (100, 80, 160, 140))
|
|
# Encoded boxes translate on BOTH axes, then clip at the visible edge.
|
|
annotations = [{0: [{"id": "3", "xyxy": [190, 4, 230, 50]}]}]
|
|
args.box_coordinate_space = "encoded"
|
|
_, _, records = frame_crops(media, annotations, .1, args)
|
|
self.assertEqual(records[0]["box"], (0, 0, 26, 38))
|
|
|
|
def test_preview_records_the_same_transformation_used_for_extraction(self):
|
|
clip = SimpleNamespace(path=Path("source.mp4"), width=2124, height=1080,
|
|
crop=(204, 0, 1920, 1080), dense_start=0.)
|
|
media = SimpleNamespace(clips=[clip], _source_frame_for_time=lambda t: (0, 0, 0.),
|
|
frame_at_or_before=lambda t: Image.new("RGB", (1920, 1080)))
|
|
annotations = [{0: [{"id": "3", "xyxy": [304, 80, 364, 140]}]}]
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
args = SimpleNamespace(box_coordinate_space="encoded", crop_expansion=1.,
|
|
video_fps=2., output_root=Path(directory))
|
|
preview_boxes(media, annotations, args, "Session_4", .2)
|
|
folder = Path(directory) / "Session_4_preview"
|
|
audit = json.loads((folder / "boxes.json").read_text())
|
|
self.assertEqual(audit[0]["coordinate_offset"], [204, 0])
|
|
self.assertEqual(audit[0]["boxes"][0]["box"], [100, 80, 160, 140])
|
|
self.assertTrue((folder / audit[0]["image"]).is_file())
|
|
|
|
def test_preview_can_save_more_than_four_images_in_a_later_range(self):
|
|
clip = SimpleNamespace(path=Path("source.mp4"), width=640, height=360,
|
|
crop=None, dense_start=0.)
|
|
media = SimpleNamespace(clips=[clip],
|
|
_source_frame_for_time=lambda t: (0, round(t * 2), t),
|
|
frame_at_or_before=lambda t: Image.new("RGB", (640, 360)))
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
args = SimpleNamespace(box_coordinate_space="camera", crop_expansion=1., video_fps=2.,
|
|
output_root=Path(directory), preview_count=8,
|
|
preview_start=2., preview_end=6., preview_with_boxes=False)
|
|
preview_boxes(media, [{}], args, "clip", 10.)
|
|
folder = Path(directory) / "clip_preview"
|
|
audit = json.loads((folder / "boxes.json").read_text())
|
|
self.assertEqual([row["time"] for row in audit], [2., 2.5, 3., 3.5, 4., 4.5, 5., 5.5])
|
|
self.assertEqual(audit[0]["image"], "frame_000004.jpg")
|
|
self.assertEqual(len(list(folder.glob("*.jpg"))), 8)
|
|
report = json.loads((folder / "preview.json").read_text())
|
|
self.assertEqual(report["saved_count"], 8)
|
|
self.assertEqual(report["saved_with_boxes"], 0)
|
|
|
|
def test_boxed_preview_filters_valid_corrected_crops_without_decoding(self):
|
|
# Encoded boxes are corrected by the crop origin, as in feature extraction.
|
|
clip = SimpleNamespace(crop=(204, 0, 640, 360), width=844, height=360)
|
|
media = SimpleNamespace(clips=[clip], _source_frame_for_time=lambda t: (0, round(t * 2), t))
|
|
valid = {"id": "p", "xyxy": [304, 80, 364, 140]}
|
|
invalid = {"id": "p", "xyxy": [4, 80, 64, 140]} # entirely inside removed banner
|
|
annotations = [{1: [valid], 3: [invalid], 5: [valid], 7: [valid]}]
|
|
args = SimpleNamespace(box_coordinate_space="encoded", crop_expansion=1., video_fps=2.,
|
|
preview_count=2, preview_start=0., preview_end=None, preview_with_boxes=True)
|
|
selected, grid_count, eligible_count = preview_ticks(media, annotations, args, 4.)
|
|
self.assertEqual(selected, [1, 7]) # uniform among valid frames, not first N
|
|
self.assertEqual((grid_count, eligible_count), (8, 3))
|
|
# No RGB reader is needed by the scan, and annotations are not changed.
|
|
self.assertEqual(len(annotations[0]), 4)
|
|
|
|
def test_empty_boxed_preview_writes_an_explicit_empty_report(self):
|
|
clip = SimpleNamespace(crop=None, width=640, height=360)
|
|
media = SimpleNamespace(clips=[clip], _source_frame_for_time=lambda t: (0, round(t * 2), t))
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
args = SimpleNamespace(box_coordinate_space="camera", crop_expansion=1., video_fps=2.,
|
|
output_root=Path(directory), preview_count=40,
|
|
preview_start=0., preview_end=None, preview_with_boxes=True)
|
|
preview_boxes(media, [{}], args, "empty_clip", 4.)
|
|
folder = Path(directory) / "empty_clip_preview"
|
|
self.assertEqual(json.loads((folder / "boxes.json").read_text()), [])
|
|
report = json.loads((folder / "preview.json").read_text())
|
|
self.assertEqual(report["eligible_frame_count"], 0)
|
|
self.assertEqual(report["saved_count"], 0)
|
|
self.assertEqual(list(folder.glob("*.jpg")), [])
|
|
|
|
def test_preview_range_clips_to_available_end_and_excludes_endpoint(self):
|
|
args = SimpleNamespace(video_fps=2., preview_start=.1, preview_end=100., preview_count=100)
|
|
self.assertEqual(preview_time_bounds(args, 1.), (.1, 1.))
|
|
self.assertEqual(preview_ticks(None, [], args, 1.), ([1], 1, 1))
|
|
args.preview_start = 2.
|
|
with self.assertRaisesRegex(ValueError, "outside the available prefix"):
|
|
preview_time_bounds(args, 1.)
|
|
|
|
def test_preview_cli_validation(self):
|
|
import contextlib
|
|
import io
|
|
|
|
required = ["--data-root", "/raw", "--feature-root", "/global", "--target-root", "/targets",
|
|
"--output-root", "/preview", "--samples", "4"]
|
|
valid = parse_args(required + ["--preview-only", "--preview-count", "40", "--preview-start", "30",
|
|
"--preview-end", "120", "--preview-with-boxes"])
|
|
self.assertEqual((valid.preview_count, valid.preview_start, valid.preview_end), (40, 30., 120.))
|
|
for extra in (["--preview-only", "--preview-count", "0"],
|
|
["--preview-only", "--preview-start", "-1"],
|
|
["--preview-only", "--preview-end", "nan"],
|
|
["--preview-only", "--preview-start", "10", "--preview-end", "10"],
|
|
["--preview-only", "--max-steps", "20", "--preview-end", "60"],
|
|
["--preview-count", "40"], ["--preview-with-boxes"]):
|
|
with self.subTest(extra=extra), contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
|
|
parse_args(required + extra)
|
|
|
|
def test_preview_end_limits_media_reader_without_loading_encoder(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
reference, _, labels = fixture()
|
|
torch.save(reference, root / "Session_4.pt")
|
|
np.save(root / "Session_4.npy", labels)
|
|
args = ["--data-root", str(root), "--feature-root", str(root), "--target-root", str(root),
|
|
"--output-root", str(root / "preview"), "--samples", "4",
|
|
"--preview-only", "--preview-end", "2", "--preview-count", "20"]
|
|
media = SimpleNamespace(clips=[], metadata=lambda: {})
|
|
with patch("extract_turn_local_features.build_media_and_boxes", return_value=(media, [])) as build, \
|
|
patch("extract_turn_local_features.preview_boxes") as preview, \
|
|
patch("extract_turn_local_features.FrozenQwenLocalEncoder") as encoder:
|
|
extract_main(args)
|
|
self.assertEqual(build.call_args.args[2], 2.)
|
|
preview.assert_called_once()
|
|
encoder.assert_not_called()
|
|
|
|
def test_sidecar_alignment_contract_and_modalities(self):
|
|
reference, side, _ = fixture()
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "Session_4.pt"
|
|
for mode, width in (("visual", 7), ("audio", 6), ("av", 13)):
|
|
torch.save(side, path)
|
|
features, contract = load_local_features(path, reference, sample_id="Session_4", dataset="easycom", modalities=mode)
|
|
self.assertEqual(features.shape, (20, width))
|
|
dims = local_head_dimensions(contract)
|
|
self.assertEqual(dims["local_visual_dim"], 0 if mode == "audio" else 6)
|
|
for field, value in (("times", side["times"] + .2), ("label_indices", torch.arange(20) + 1),
|
|
("sample_id", "Session_5"), ("partial", True),
|
|
("visual", torch.full((20, 6), float("nan"))),
|
|
("visual_valid", torch.ones(20))):
|
|
broken = {**side, field: value}
|
|
torch.save(broken, path)
|
|
with self.subTest(field=field), self.assertRaises(ValueError):
|
|
load_local_features(path, reference, sample_id="Session_4", dataset="easycom", modalities="av")
|
|
torch.save(side, path)
|
|
wrong_contract = copy.deepcopy(contract)
|
|
wrong_contract["config"]["encoder_id"] = "other-weights"
|
|
with self.assertRaisesRegex(ValueError, "contract"):
|
|
load_local_features(path, reference, sample_id="Session_4", dataset="easycom", modalities="av", expected_contract=wrong_contract)
|
|
with self.assertRaisesRegex(ValueError, "times"):
|
|
reference_alignment({**reference, "times": reference["times"] + .2})
|
|
|
|
def test_frame_set_loader_is_causal_ragged_and_fail_closed(self):
|
|
reference, side, _ = fixture()
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "Session_4.pt"
|
|
torch.save(side, path)
|
|
audio, people, contract = load_frame_set_features(
|
|
path,
|
|
reference,
|
|
sample_id="Session_4",
|
|
dataset="easycom",
|
|
)
|
|
self.assertEqual(audio.shape, (20, 6))
|
|
self.assertEqual(contract["modalities"], "av")
|
|
self.assertEqual(people.dimension, 6)
|
|
self.assertTrue(
|
|
torch.all(
|
|
people.chosen_frame_times[
|
|
torch.isfinite(people.chosen_frame_times)
|
|
]
|
|
< reference["times"][
|
|
torch.isfinite(people.chosen_frame_times)
|
|
]
|
|
)
|
|
)
|
|
# At 0.2/0.4s, both rows reuse the 0.0s frame with two people.
|
|
values, mask = people.padded(torch.tensor([0, 1, 2]))
|
|
self.assertEqual(values.shape, (3, 2, 6))
|
|
self.assertEqual(mask.tolist(), [[True, True], [True, True], [False, False]])
|
|
self.assertTrue(torch.equal(values[0], values[1]))
|
|
self.assertEqual(torch.count_nonzero(values[2]), 0)
|
|
# The frame stamped exactly 1.0s is excluded at the 1.0s cutoff.
|
|
self.assertEqual(people.chosen_frame_times[4].item(), .5)
|
|
|
|
broken_cases = []
|
|
broken = copy.deepcopy(side)
|
|
broken["persons"]["frame_offsets"][-1] -= 1
|
|
broken_cases.append(broken)
|
|
broken = copy.deepcopy(side)
|
|
broken["persons"]["frame_times"][1] = broken["persons"]["frame_times"][0]
|
|
broken_cases.append(broken)
|
|
broken = copy.deepcopy(side)
|
|
broken["persons"]["records"] = []
|
|
broken_cases.append(broken)
|
|
broken = copy.deepcopy(side)
|
|
broken["persons"]["features"][0, 0] = float("nan")
|
|
broken_cases.append(broken)
|
|
for index, broken in enumerate(broken_cases):
|
|
torch.save(broken, path)
|
|
with self.subTest(index=index), self.assertRaises(ValueError):
|
|
load_frame_set_features(
|
|
path,
|
|
reference,
|
|
sample_id="Session_4",
|
|
dataset="easycom",
|
|
)
|
|
|
|
def test_track_loader_builds_causal_histories_and_resets_tracks(self):
|
|
reference, side, _ = fixture(n=12)
|
|
frame_times = torch.tensor([0., .5, 1., 1.5, 2.], dtype=torch.float64)
|
|
side["persons"] = {
|
|
"features": torch.arange(1, 9, dtype=torch.float32)[:, None].repeat(1, 6),
|
|
"frame_times": frame_times,
|
|
"frame_offsets": torch.tensor([0, 2, 4, 6, 7, 8]),
|
|
"records": [
|
|
{"id": "A", "source_clip_index": 0},
|
|
{"id": "B", "source_clip_index": 0},
|
|
{"id": "B", "source_clip_index": 0},
|
|
{"id": "A", "source_clip_index": 0},
|
|
{"id": "A", "source_clip_index": 0},
|
|
{"id": -1, "source_clip_index": 0},
|
|
# The same ID in another retained source clip is a new track.
|
|
{"id": "A", "source_clip_index": 1},
|
|
{"id": "A", "source_clip_index": 1},
|
|
],
|
|
}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / "Session_4.pt"
|
|
torch.save(side, path)
|
|
pooled, people, contract = load_track_features(
|
|
path,
|
|
reference,
|
|
sample_id="Session_4",
|
|
dataset="easycom",
|
|
track_max_gap_seconds=.75,
|
|
)
|
|
self.assertEqual(pooled.shape, (12, 13))
|
|
self.assertEqual(contract["modalities"], "av")
|
|
self.assertEqual(
|
|
people.previous_observation.tolist(),
|
|
[-1, -1, 1, 0, 3, -1, -1, 6],
|
|
)
|
|
# Cutoff 1.2s selects frame 1.0s. A has observations 1,4,5;
|
|
# anonymous -1 remains a one-observation track.
|
|
history, mask = people.padded_history(
|
|
torch.tensor([5]),
|
|
max_observations=4,
|
|
)
|
|
self.assertEqual(mask.tolist(), [[[True, True, True, False],
|
|
[True, False, False, False]]])
|
|
self.assertEqual(history[0, 0, :, 0].tolist(), [1., 4., 5., 0.])
|
|
self.assertEqual(history[0, 1, :, 0].tolist(), [6., 0., 0., 0.])
|
|
# Cutoff 1.8s selects frame 1.5s; source clip reset prevents A's
|
|
# earlier observations from crossing the boundary.
|
|
history, mask = people.padded_history(
|
|
torch.tensor([8]),
|
|
max_observations=4,
|
|
)
|
|
self.assertEqual(mask.tolist(), [[[True, False, False, False]]])
|
|
self.assertEqual(history[0, 0, :, 0].tolist(), [7., 0., 0., 0.])
|
|
|
|
broken = copy.deepcopy(side)
|
|
broken["persons"]["records"][1]["id"] = "A"
|
|
torch.save(broken, path)
|
|
with self.assertRaisesRegex(ValueError, "Duplicate person/track ID"):
|
|
load_track_features(
|
|
path,
|
|
reference,
|
|
sample_id="Session_4",
|
|
dataset="easycom",
|
|
track_max_gap_seconds=.75,
|
|
)
|
|
|
|
def test_frame_person_concatenation_selection_and_empty_padding(self):
|
|
features = torch.tensor([[1., 2.], [3., 4.]])
|
|
a = FramePersonFeatures(
|
|
features,
|
|
torch.tensor([0, 2], dtype=torch.long),
|
|
torch.tensor([0, -1], dtype=torch.long),
|
|
torch.tensor([0., float("nan")], dtype=torch.float64),
|
|
)
|
|
b = FramePersonFeatures(
|
|
torch.empty(0, 2),
|
|
torch.tensor([0, 0], dtype=torch.long),
|
|
torch.tensor([0], dtype=torch.long),
|
|
torch.tensor([.5], dtype=torch.float64),
|
|
)
|
|
combined = FramePersonFeatures.concatenate([a, b])
|
|
self.assertEqual(len(combined), 3)
|
|
self.assertEqual(combined.row_to_frame.tolist(), [0, -1, 1])
|
|
selected = combined.select_rows(torch.tensor([2, 0]))
|
|
values, mask = selected.padded()
|
|
self.assertEqual(mask.tolist(), [[False, False], [True, True]])
|
|
self.assertEqual(torch.count_nonzero(values[0]), 0)
|
|
self.assertTrue(torch.equal(values[1], features))
|
|
|
|
def test_frame_set_head_is_permutation_invariant_and_masks_padding(self):
|
|
torch.manual_seed(7)
|
|
head = TurnPredictionHead(
|
|
hidden_size=8,
|
|
num_input_layers=4,
|
|
layer_fusion="concat-mlp",
|
|
local_visual_dim=6,
|
|
local_audio_dim=5,
|
|
local_projection_dim=3,
|
|
local_fusion="frame-set-attention",
|
|
person_attention_dim=4,
|
|
person_attention_heads=2,
|
|
transition_auxiliary=True,
|
|
dropout=0,
|
|
).eval()
|
|
states = torch.randn(3, 4, 8)
|
|
local = torch.randn(3, 6)
|
|
local[:, -1] = 1
|
|
people = torch.randn(3, 3, 6)
|
|
mask = torch.tensor(
|
|
[[True, True, False], [True, False, False], [False, False, False]]
|
|
)
|
|
people[~mask] = 0
|
|
logits, transitions = head.forward_with_transition(
|
|
states,
|
|
local,
|
|
people,
|
|
mask,
|
|
)
|
|
self.assertEqual(logits.shape, (3, 10, 3))
|
|
self.assertEqual(transitions.shape, (3, 10))
|
|
(logits.square().mean() + transitions.square().mean()).backward()
|
|
self.assertIsNotNone(head.person_attention.in_proj_weight.grad)
|
|
|
|
permutation = torch.tensor([1, 0, 2])
|
|
permuted = head(states, local, people[:, permutation], mask[:, permutation])
|
|
self.assertTrue(torch.allclose(logits, permuted, atol=1e-6, rtol=1e-6))
|
|
changed = people.clone()
|
|
changed[~mask] = 1000
|
|
self.assertTrue(torch.allclose(logits, head(states, local, changed, mask)))
|
|
with self.assertRaisesRegex(ValueError, "person features"):
|
|
head(states, local)
|
|
|
|
config = {
|
|
"hidden_size": 8,
|
|
"num_input_layers": 4,
|
|
"layer_fusion": "concat-mlp",
|
|
"local_visual_dim": 6,
|
|
"local_audio_dim": 5,
|
|
"local_projection_dim": 3,
|
|
"local_fusion": "frame-set-attention",
|
|
"person_attention_dim": 4,
|
|
"person_attention_heads": 2,
|
|
"transition_auxiliary": True,
|
|
"dropout": 0,
|
|
}
|
|
restored = TurnPredictionHead(
|
|
**checkpoint_head_config({"head_config": config})
|
|
)
|
|
restored.load_state_dict(head.state_dict())
|
|
restored.eval()
|
|
self.assertTrue(torch.equal(restored(states, local, people, mask), logits))
|
|
|
|
def test_f5_screening_audio_person_read_is_gated_and_permutation_invariant(self):
|
|
torch.manual_seed(11)
|
|
common = {
|
|
"hidden_size": 8,
|
|
"local_visual_dim": 6,
|
|
"local_audio_dim": 5,
|
|
"local_projection_dim": 3,
|
|
"person_attention_dim": 4,
|
|
"person_attention_heads": 2,
|
|
"dropout": 0,
|
|
}
|
|
f1 = TurnPredictionHead(
|
|
**common,
|
|
local_fusion="frame-set-attention",
|
|
).eval()
|
|
f5 = TurnPredictionHead(
|
|
**common,
|
|
local_fusion="audio-person-frame-set",
|
|
).eval()
|
|
# Align all shared parameters. A zero F5 gate must reproduce F1
|
|
# exactly, independent of the newly initialized reverse-read block.
|
|
f5_state = f5.state_dict()
|
|
for name, value in f1.state_dict().items():
|
|
if name in f5_state and f5_state[name].shape == value.shape:
|
|
f5_state[name] = value
|
|
f5.load_state_dict(f5_state)
|
|
states = torch.randn(3, 8)
|
|
local = torch.randn(3, 6)
|
|
local[:, -1] = 1
|
|
people = torch.randn(3, 3, 6)
|
|
mask = torch.tensor(
|
|
[[True, True, False], [True, False, False], [False, False, False]]
|
|
)
|
|
people[~mask] = 0
|
|
baseline = f1(states, local, people, mask)
|
|
self.assertTrue(torch.equal(f5(states, local, people, mask), baseline))
|
|
self.assertEqual(f5.person_interaction_gates(), {"audio_person": 0.0})
|
|
|
|
with torch.no_grad():
|
|
assert f5.audio_person_gate is not None
|
|
f5.audio_person_gate.fill_(0.25)
|
|
logits = f5(states, local, people, mask)
|
|
self.assertFalse(torch.equal(logits, baseline))
|
|
permutation = torch.tensor([1, 0, 2])
|
|
self.assertTrue(
|
|
torch.allclose(
|
|
logits,
|
|
f5(states, local, people[:, permutation], mask[:, permutation]),
|
|
atol=1e-6,
|
|
rtol=1e-6,
|
|
)
|
|
)
|
|
logits.square().mean().backward()
|
|
self.assertIsNotNone(f5.audio_person_attention.in_proj_weight.grad)
|
|
self.assertIsNotNone(f5.audio_person_gate.grad)
|
|
|
|
def test_f6_screening_adapter_is_zero_gated_and_visual_only_when_valid(self):
|
|
torch.manual_seed(13)
|
|
head = TurnPredictionHead(
|
|
hidden_size=8,
|
|
local_visual_dim=6,
|
|
local_audio_dim=5,
|
|
local_projection_dim=3,
|
|
local_fusion="gated-person-state-adapter",
|
|
person_attention_dim=4,
|
|
person_attention_heads=2,
|
|
dropout=0,
|
|
).eval()
|
|
states = torch.randn(3, 8)
|
|
local = torch.randn(3, 6)
|
|
local[:, -1] = 1
|
|
people = torch.randn(3, 2, 6)
|
|
mask = torch.tensor([[True, True], [True, False], [False, False]])
|
|
people[~mask] = 0
|
|
zero_gate = head(states, local, people, mask)
|
|
changed = people.clone()
|
|
changed[mask] += torch.arange(6, dtype=changed.dtype) * 10
|
|
# Before training, person memory is an exact zero residual.
|
|
self.assertTrue(torch.equal(head(states, local, changed, mask), zero_gate))
|
|
self.assertEqual(
|
|
head.person_interaction_gates(),
|
|
{"person_state_adapter": 0.0},
|
|
)
|
|
|
|
with torch.no_grad():
|
|
assert head.person_state_adapter_gate is not None
|
|
head.person_state_adapter_gate.fill_(0.25)
|
|
logits = head(states, local, people, mask)
|
|
self.assertFalse(torch.equal(head(states, local, changed, mask), logits))
|
|
# An empty set must remain visually inert even after opening the gate.
|
|
empty_changed = people.clone()
|
|
empty_changed[2] = 1000
|
|
self.assertTrue(
|
|
torch.equal(
|
|
logits[2],
|
|
head(states, local, empty_changed, mask)[2],
|
|
)
|
|
)
|
|
permutation = torch.tensor([1, 0])
|
|
self.assertTrue(
|
|
torch.allclose(
|
|
logits,
|
|
head(states, local, people[:, permutation], mask[:, permutation]),
|
|
atol=1e-6,
|
|
rtol=1e-6,
|
|
)
|
|
)
|
|
logits.square().mean().backward()
|
|
self.assertIsNotNone(head.person_attention.in_proj_weight.grad)
|
|
self.assertIsNotNone(head.person_state_adapter[-1].weight.grad)
|
|
self.assertIsNotNone(head.person_state_adapter_gate.grad)
|
|
|
|
config = {
|
|
"hidden_size": 8,
|
|
"local_visual_dim": 6,
|
|
"local_audio_dim": 5,
|
|
"local_projection_dim": 3,
|
|
"local_fusion": "gated-person-state-adapter",
|
|
"person_attention_dim": 4,
|
|
"person_attention_heads": 2,
|
|
"dropout": 0,
|
|
}
|
|
restored = TurnPredictionHead(
|
|
**checkpoint_head_config({"head_config": config})
|
|
)
|
|
restored.load_state_dict(head.state_dict())
|
|
restored.eval()
|
|
self.assertTrue(torch.equal(restored(states, local, people, mask), logits))
|
|
|
|
def test_track_gru_residual_starts_from_exact_pooled_logits(self):
|
|
torch.manual_seed(17)
|
|
common = {
|
|
"hidden_size": 8,
|
|
"num_input_layers": 4,
|
|
"layer_fusion": "concat-mlp",
|
|
"local_visual_dim": 6,
|
|
"local_audio_dim": 5,
|
|
"local_projection_dim": 3,
|
|
"dropout": 0,
|
|
}
|
|
pooled = TurnPredictionHead(**common, local_fusion="pooled").eval()
|
|
track = TurnPredictionHead(
|
|
**common,
|
|
local_fusion="track-gru-residual",
|
|
person_attention_dim=4,
|
|
person_attention_heads=2,
|
|
person_history_frames=3,
|
|
).eval()
|
|
track_state = track.state_dict()
|
|
for name, value in pooled.state_dict().items():
|
|
self.assertIn(name, track_state)
|
|
self.assertEqual(track_state[name].shape, value.shape)
|
|
track_state[name] = value
|
|
track.load_state_dict(track_state)
|
|
|
|
states = torch.randn(3, 4, 8)
|
|
local = torch.randn(3, 13)
|
|
local[:, 6] = 1
|
|
local[:, 12] = 1
|
|
histories = torch.randn(3, 2, 3, 6)
|
|
mask = torch.tensor(
|
|
[
|
|
[[True, True, True], [True, True, False]],
|
|
[[True, False, False], [False, False, False]],
|
|
[[False, False, False], [False, False, False]],
|
|
]
|
|
)
|
|
histories[~mask] = 0
|
|
baseline = pooled(states, local)
|
|
self.assertTrue(torch.equal(track(states, local, histories, mask), baseline))
|
|
self.assertEqual(track.person_interaction_gates(), {"person_track_residual": 0.0})
|
|
|
|
with torch.no_grad():
|
|
assert track.person_track_residual_gate is not None
|
|
track.person_track_residual_gate.fill_(.25)
|
|
logits = track(states, local, histories, mask)
|
|
self.assertFalse(torch.equal(logits[:2], baseline[:2]))
|
|
# With no current person the correction must be exactly zero.
|
|
self.assertTrue(torch.equal(logits[2], baseline[2]))
|
|
permutation = torch.tensor([1, 0])
|
|
self.assertTrue(
|
|
torch.allclose(
|
|
logits,
|
|
track(states, local, histories[:, permutation], mask[:, permutation]),
|
|
atol=1e-6,
|
|
rtol=1e-6,
|
|
)
|
|
)
|
|
changed_padding = histories.clone()
|
|
changed_padding[~mask] = 1000
|
|
self.assertTrue(torch.equal(logits, track(states, local, changed_padding, mask)))
|
|
logits.square().mean().backward()
|
|
self.assertIsNotNone(track.person_track_gru.weight_ih_l0.grad)
|
|
self.assertIsNotNone(track.person_track_delta[-1].weight.grad)
|
|
self.assertIsNotNone(track.person_track_residual_gate.grad)
|
|
|
|
config = {
|
|
**common,
|
|
"local_fusion": "track-gru-residual",
|
|
"person_attention_dim": 4,
|
|
"person_attention_heads": 2,
|
|
"person_history_frames": 3,
|
|
}
|
|
restored = TurnPredictionHead(
|
|
**checkpoint_head_config({"head_config": config})
|
|
).eval()
|
|
restored.load_state_dict(track.state_dict())
|
|
self.assertTrue(torch.equal(restored(states, local, histories, mask), logits))
|
|
|
|
def test_head_shapes_gradients_missing_and_roundtrip(self):
|
|
states = torch.randn(3, 4, 8)
|
|
for v, a in ((0, 0), (6, 0), (0, 5), (6, 5)):
|
|
for projection in (0, 3):
|
|
head = TurnPredictionHead(hidden_size=8, num_input_layers=4, layer_fusion="concat-mlp",
|
|
local_visual_dim=v, local_audio_dim=a, local_projection_dim=projection,
|
|
transition_auxiliary=True, dropout=0)
|
|
width = sum(d + 1 for d in (v, a) if d)
|
|
local = torch.randn(3, width)
|
|
offset = 0
|
|
for d in (v, a):
|
|
if d:
|
|
local[:, offset + d] = torch.tensor([0., 1., 1.])
|
|
offset += d + 1
|
|
logits, transition = head.forward_with_transition(states, local)
|
|
self.assertEqual(logits.shape, (3, 10, 3))
|
|
self.assertEqual(transition.shape, (3, 10))
|
|
(logits.square().mean() + transition.square().mean()).backward()
|
|
self.assertIsNotNone(head.net[1].weight.grad)
|
|
if width:
|
|
# Changing invalid raw features cannot change the readout.
|
|
changed = local.clone()
|
|
offset = 0
|
|
for d in (v, a):
|
|
if d:
|
|
changed[0, offset:offset + d] = 1000
|
|
offset += d + 1
|
|
self.assertTrue(torch.equal(head(states, local)[0], head(states, changed)[0]))
|
|
with self.assertRaisesRegex(ValueError, "requires aligned"):
|
|
head(states)
|
|
config = {"hidden_size": 8, "num_input_layers": 4, "layer_fusion": "concat-mlp",
|
|
"local_visual_dim": v, "local_audio_dim": a, "local_projection_dim": projection,
|
|
"transition_auxiliary": True, "dropout": 0}
|
|
restored = TurnPredictionHead(**checkpoint_head_config({"head_config": config}))
|
|
restored.load_state_dict(head.state_dict())
|
|
self.assertTrue(torch.equal(restored(states, local), head(states, local)))
|
|
|
|
def test_legacy_head_parameter_keys_and_values(self):
|
|
torch.manual_seed(42)
|
|
old = torch.nn.Sequential(torch.nn.LayerNorm(8), torch.nn.Linear(8, 4), torch.nn.GELU(),
|
|
torch.nn.Dropout(.1), torch.nn.Linear(4, 30))
|
|
torch.manual_seed(42)
|
|
head = TurnPredictionHead(hidden_size=8)
|
|
self.assertEqual(set(head.state_dict()), {f"net.{key}" for key in old.state_dict()})
|
|
for key, value in old.state_dict().items():
|
|
self.assertTrue(torch.equal(value, head.state_dict()[f"net.{key}"]))
|
|
|
|
def test_sparse_selection_keeps_local_rows(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
for folder in ("features", "local", "targets"):
|
|
(root / folder).mkdir()
|
|
reference, side, targets = fixture()
|
|
torch.save(reference, root / "features/Session_4.pt")
|
|
torch.save(side, root / "local/Session_4.pt")
|
|
np.save(root / "targets/Session_4.npy", targets)
|
|
data = _load_examples(root / "features", root / "targets", ["Session_4"], "open-stream-replay",
|
|
"easycom", {"Session_4": {1, 4}}, root / "local", "audio")
|
|
self.assertTrue(torch.equal(data[-2][:, :5], side["audio"][[1, 4]]))
|
|
self.assertIsNone(data[-1])
|
|
self.assertTrue(torch.equal(data[0], reference["states"][[1, 4]]))
|
|
self.assertTrue(torch.equal(data[1][:, 0], torch.from_numpy(targets[[2, 5]])))
|
|
|
|
def test_source_inventory_rejects_different_clip_order(self):
|
|
source = {"path": "/old/00-00-000.mp4", "source_frames": 1200, "dense_start": 0., "duration": 60.}
|
|
media = SimpleNamespace(clips=[object()], metadata=lambda: {"source_clips": [source]})
|
|
validate_source_timeline({"direct_media": {"source_clips": [source]}}, media)
|
|
bad = {**source, "path": "/old/01-00-000.mp4"}
|
|
with self.assertRaisesRegex(ValueError, "timeline differs"):
|
|
validate_source_timeline({"direct_media": {"source_clips": [bad]}}, media)
|
|
|
|
def test_config_digest_ignores_only_checkpoint_location(self):
|
|
a = {"_name_or_path": "/old/model", "audio_config": {"_name_or_path": "/old/model", "output_dim": 8}}
|
|
b = {"_name_or_path": "/new/model", "audio_config": {"_name_or_path": "/new/model", "output_dim": 8}}
|
|
self.assertEqual(model_config_digest(a), model_config_digest(b))
|
|
b["audio_config"]["output_dim"] = 16
|
|
self.assertNotEqual(model_config_digest(a), model_config_digest(b))
|
|
|
|
def test_encoder_image_pool_splits_people_before_mean(self):
|
|
encoder = FrozenQwenLocalEncoder.__new__(FrozenQwenLocalEncoder)
|
|
encoder.visual_dim, encoder.device, encoder.image_batch_size, encoder.max_pixels = 2, torch.device("cpu"), 8, 50176
|
|
encoder.processor = SimpleNamespace(image_processor=lambda **kwargs: {
|
|
"pixel_values": torch.ones(12, 2), "image_grid_thw": torch.tensor([[1, 2, 2], [1, 2, 4]])})
|
|
encoder.model = SimpleNamespace(visual=SimpleNamespace(dtype=torch.float32),
|
|
config=SimpleNamespace(vision_config=SimpleNamespace(spatial_merge_size=2)),
|
|
get_image_features=lambda *_: torch.tensor([[1., 1.], [3., 3.], [5., 5.]]))
|
|
actual = encoder.images([object(), object()])
|
|
self.assertTrue(torch.equal(actual, torch.tensor([[1., 1.], [4., 4.]])))
|
|
# A future API returning per-image tensors must not silently change
|
|
# the pooling rule or crash with an uninformative AttributeError.
|
|
encoder.model.get_image_features = lambda *_: (torch.ones(1, 2), torch.ones(2, 2))
|
|
with self.assertRaisesRegex(RuntimeError, "Unexpected image encoder output"):
|
|
encoder.images([object(), object()])
|
|
|
|
def test_extraction_saves_runtime_version_and_rejects_mixed_versions(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
for folder in ("features", "targets", "local"):
|
|
(root / folder).mkdir()
|
|
reference, side, labels = fixture()
|
|
torch.save(reference, root / "features/Session_4.pt")
|
|
np.save(root / "targets/Session_4.npy", labels)
|
|
media = SimpleNamespace(clips=[], metadata=lambda: {})
|
|
argv = ["extract_turn_local_features.py", "--data-root", str(root),
|
|
"--feature-root", str(root / "features"), "--target-root", str(root / "targets"),
|
|
"--output-root", str(root / "local"), "--samples", "4", "--overwrite"]
|
|
contracts = []
|
|
for version in TESTED_TRANSFORMERS_VERSIONS:
|
|
fake_encoder = SimpleNamespace(transformers_version=version, visual_dim=6, audio_dim=5,
|
|
model_config_sha256="same-checkpoint")
|
|
with patch.object(sys, "argv", argv), \
|
|
patch.dict(sys.modules, {"transformers": SimpleNamespace(__version__=version)}), \
|
|
patch("extract_turn_local_features.build_media_and_boxes", return_value=(media, [])), \
|
|
patch("extract_turn_local_features.FrozenQwenLocalEncoder", return_value=fake_encoder), \
|
|
patch("extract_turn_local_features.extract_local", return_value=copy.deepcopy(side)):
|
|
extract_main()
|
|
output = root / "local/Session_4.pt"
|
|
_, contract = load_local_features(output, reference, sample_id="Session_4",
|
|
dataset="easycom", modalities="av")
|
|
self.assertEqual(contract["config"]["transformers"], version)
|
|
contracts.append(contract)
|
|
with self.assertRaisesRegex(ValueError, "contract differs"):
|
|
load_local_features(output, reference, sample_id="Session_4", dataset="easycom",
|
|
modalities="av", expected_contract=contracts[0])
|
|
|
|
def test_unsupported_version_fails_before_media_and_preview_still_works(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
reference, _, labels = fixture()
|
|
torch.save(reference, root / "Session_4.pt")
|
|
np.save(root / "Session_4.npy", labels)
|
|
argv = ["extract_turn_local_features.py", "--data-root", str(root),
|
|
"--feature-root", str(root), "--target-root", str(root),
|
|
"--output-root", str(root / "preview"), "--samples", "4"]
|
|
with patch.object(sys, "argv", argv), \
|
|
patch.dict(sys.modules, {"transformers": SimpleNamespace(__version__="5.0.0")}), \
|
|
patch("extract_turn_local_features.build_media_and_boxes") as build:
|
|
with self.assertRaisesRegex(RuntimeError, "found 5.0.0"):
|
|
extract_main()
|
|
build.assert_not_called()
|
|
media = SimpleNamespace(clips=[], metadata=lambda: {})
|
|
with patch.object(sys, "argv", argv + ["--preview-only"]), \
|
|
patch.dict(sys.modules, {"transformers": SimpleNamespace(__version__="5.0.0")}), \
|
|
patch("extract_turn_local_features.build_media_and_boxes", return_value=(media, [])), \
|
|
patch("extract_turn_local_features.preview_boxes") as preview, \
|
|
patch("extract_turn_local_features.FrozenQwenLocalEncoder") as encoder:
|
|
extract_main()
|
|
preview.assert_called_once()
|
|
encoder.assert_not_called()
|
|
|
|
def test_extraction_never_encodes_future_audio_and_saves_ragged_people(self):
|
|
class Media:
|
|
clips = [SimpleNamespace(crop=None, dense_start=0.)]
|
|
def _source_frame_for_time(self, t):
|
|
return 0, round(t * 20), t
|
|
def frame_at_or_before(self, t):
|
|
return Image.new("RGB", (16, 16))
|
|
def audio_slice(self, start, end):
|
|
return np.arange(round(start * 16000), round(end * 16000), dtype=np.float32)
|
|
class Encoder:
|
|
visual_dim = audio_dim = 2
|
|
def __init__(self):
|
|
self.ends = []
|
|
def images(self, crops):
|
|
return torch.ones(len(crops), 2)
|
|
def audio(self, waveform):
|
|
self.ends.append(float(waveform[-1]))
|
|
return torch.tensor([float(waveform[0]), float(waveform[-1])])
|
|
encoder = Encoder()
|
|
args = SimpleNamespace(video_fps=2., visual_window_seconds=1., audio_window_seconds=1.,
|
|
box_coordinate_space="camera", crop_expansion=1., no_progress=True)
|
|
annotations = [{0: [{"id": "1", "xyxy": [0., 0., 8., 8.]}]}]
|
|
result = extract_local(Media(), annotations, encoder, torch.arange(1, 11, dtype=torch.float64) / 5, args)
|
|
self.assertEqual(encoder.ends, [3200 * (i + 1) - 1 for i in range(10)])
|
|
self.assertEqual(result["persons"]["frame_offsets"].tolist(), [0, 1, 1, 1, 1])
|
|
self.assertTrue(result["visual_valid"][:5].all())
|
|
self.assertFalse(result["visual_valid"][5:].any())
|
|
self.assertTrue(result["audio_valid"].all())
|
|
|
|
|
|
@unittest.skipUnless(importlib.util.find_spec("transformers"), "optional: install a tested Transformers version")
|
|
class TinyQwenEncoderTests(unittest.TestCase):
|
|
def setUp(self):
|
|
import transformers
|
|
if transformers.__version__ not in TESTED_TRANSFORMERS_VERSIONS:
|
|
self.skipTest(f"Encoder API tests require one of {TESTED_TRANSFORMERS_VERSIONS}")
|
|
torch.set_num_threads(1)
|
|
|
|
def tiny_model_and_processor(self):
|
|
from transformers import (
|
|
Qwen2_5OmniThinkerConfig, Qwen2_5OmniThinkerForConditionalGeneration,
|
|
Qwen2VLImageProcessor, WhisperFeatureExtractor,
|
|
)
|
|
config = Qwen2_5OmniThinkerConfig(
|
|
audio_config=dict(d_model=16, encoder_layers=1, encoder_attention_heads=2,
|
|
encoder_ffn_dim=32, output_dim=8),
|
|
vision_config=dict(depth=1, hidden_size=16, intermediate_size=32, num_heads=2,
|
|
out_hidden_size=8, fullatt_block_indexes=[0]),
|
|
text_config=dict(vocab_size=32, hidden_size=8, intermediate_size=16,
|
|
num_hidden_layers=1, num_attention_heads=2, num_key_value_heads=2),
|
|
)
|
|
model = Qwen2_5OmniThinkerForConditionalGeneration(config).eval().requires_grad_(False)
|
|
processor = SimpleNamespace(image_processor=Qwen2VLImageProcessor(),
|
|
feature_extractor=WhisperFeatureExtractor(feature_size=128))
|
|
return model, processor
|
|
|
|
def test_real_encoder_apis_without_downloading_weights(self):
|
|
encoder = FrozenQwenLocalEncoder.__new__(FrozenQwenLocalEncoder)
|
|
encoder.model, encoder.processor = self.tiny_model_and_processor()
|
|
encoder.model.model = None
|
|
encoder.model.lm_head = None
|
|
encoder.visual_dim = encoder.audio_dim = 8
|
|
encoder.device, encoder.max_pixels, encoder.image_batch_size = torch.device("cpu"), 224 * 224, 8
|
|
crops = [Image.new("RGB", (56, 84), "red"), Image.new("RGB", (112, 56), "blue")]
|
|
batch = encoder.images(crops)
|
|
single = encoder.images(crops[:1])
|
|
self.assertEqual(batch.shape, (2, 8))
|
|
self.assertTrue(torch.allclose(batch[0], single[0], atol=1e-5))
|
|
for n in (3200, 6400, 16000):
|
|
audio = encoder.audio(np.sin(np.arange(n, dtype=np.float32) / 10))
|
|
self.assertEqual(audio.shape, (8,))
|
|
self.assertTrue(torch.isfinite(audio).all())
|
|
self.assertFalse(audio.requires_grad)
|
|
|
|
@unittest.skipUnless(importlib.util.find_spec("accelerate"), "optional: install accelerate for checkpoint loading")
|
|
def test_constructor_loads_local_checkpoint_and_preserves_encoder_weights(self):
|
|
import transformers
|
|
|
|
model, processor = self.tiny_model_and_processor()
|
|
expected = {key: value.clone() for key, value in model.state_dict().items()
|
|
if key.startswith(("visual.", "audio_tower."))}
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
model.save_pretrained(directory)
|
|
args = SimpleNamespace(model_id=directory, device="cpu", dtype="float32",
|
|
flash_attention_2=False, max_pixels=224 * 224, image_batch_size=8)
|
|
with patch.object(transformers.Qwen2_5OmniProcessor, "from_pretrained", return_value=processor) as load:
|
|
encoder = FrozenQwenLocalEncoder(args)
|
|
load.assert_called_once_with(directory, use_fast=False)
|
|
self.assertEqual(encoder.transformers_version, transformers.__version__)
|
|
self.assertIsNone(encoder.model.model)
|
|
self.assertIsNone(encoder.model.lm_head)
|
|
self.assertFalse(encoder.model.training)
|
|
self.assertFalse(any(p.requires_grad for p in encoder.model.parameters()))
|
|
for key, value in encoder.model.state_dict().items():
|
|
self.assertTrue(torch.equal(value, expected[key]), key)
|
|
self.assertEqual(encoder.images([Image.new("RGB", (56, 84), "red")]).shape, (1, 8))
|
|
self.assertEqual(encoder.audio(np.zeros(3200, dtype=np.float32)).shape, (8,))
|
|
|
|
def test_constructor_rejects_incomplete_encoder_weights(self):
|
|
import transformers
|
|
|
|
args = SimpleNamespace(model_id="unused", device="cpu", dtype="float32", flash_attention_2=False)
|
|
for info in ({"missing_keys": ["visual.patch_embed.proj.weight"]},
|
|
{"missing_keys": ["audio_tower.conv1.weight"]},
|
|
{"mismatched_keys": [("visual.weight", (2,), (3,))]},
|
|
{"error_msgs": ["broken checkpoint"]}):
|
|
with self.subTest(info=info), \
|
|
patch.object(transformers.Qwen2_5OmniThinkerForConditionalGeneration,
|
|
"from_pretrained", return_value=(None, info)), \
|
|
self.assertRaisesRegex(RuntimeError, "incompletely loaded weights"):
|
|
FrozenQwenLocalEncoder(args)
|
|
|
|
|
|
class RoundTripTests(unittest.TestCase):
|
|
def test_train_infer_easycom_and_ego4d(self):
|
|
repo = Path(__file__).resolve().parent
|
|
env = dict(os.environ, OMP_NUM_THREADS="1", MKL_NUM_THREADS="1", OPENBLAS_NUM_THREADS="1")
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
for folder in ("features", "local", "targets"):
|
|
(root / folder).mkdir()
|
|
train_uid = "00000000-0000-0000-0000-000000000004"
|
|
test_uid = "00000000-0000-0000-0000-000000000001"
|
|
manifest = root / "ego4d.tsv"
|
|
manifest.write_text(f"split\tuid\ntrain\t{train_uid}\ntest\t{test_uid}\n")
|
|
for name in ("Session_1", "Session_4", "Session_5", train_uid, test_uid):
|
|
dataset = "easycom" if name.startswith("Session") else "ego4d"
|
|
reference, side, targets = fixture(name=name, dataset=dataset)
|
|
torch.save(reference, root / "features" / f"{name}.pt")
|
|
torch.save(side, root / "local" / f"{name}.pt")
|
|
np.save(root / "targets" / f"{name}.npy", targets)
|
|
common = ["--feature-root", str(root / "features"), "--target-root", str(root / "targets"),
|
|
"--device", "cpu", "--no-progress", "--expected-mode", "open-stream-replay"]
|
|
for mode in (
|
|
"baseline",
|
|
"audio",
|
|
"visual",
|
|
"av",
|
|
"track-gru",
|
|
"frame-set",
|
|
"f5-screen",
|
|
"f6-screen",
|
|
"ego4d",
|
|
):
|
|
checkpoint = root / f"{mode}.pth"
|
|
train = [sys.executable, str(repo / "train_turn_head.py"), *common,
|
|
"--epochs", "1", "--warmup-epochs", "0", "--batch-size", "4",
|
|
"--layer-fusion", "concat-mlp", "--output", str(checkpoint)]
|
|
infer = [sys.executable, str(repo / "infer_turn_head.py"), *common,
|
|
"--checkpoint", str(checkpoint), "--output-dir", str(root / f"result_{mode}")]
|
|
if mode == "ego4d":
|
|
train += ["--dataset", "ego4d", "--manifest", str(manifest)]
|
|
infer += ["--dataset", "ego4d", "--manifest", str(manifest)]
|
|
else:
|
|
train += ["--sessions", "4", "5"]
|
|
infer += ["--sessions", "1"]
|
|
if mode == "visual":
|
|
train += ["--dev-sessions", "5", "--local-projection-dim", "0"]
|
|
if mode == "av":
|
|
train += ["--transition-objective", "r1-supervised"]
|
|
person_mode = {
|
|
"track-gru": "track-gru-residual",
|
|
"frame-set": "frame-set-attention",
|
|
"f5-screen": "audio-person-frame-set",
|
|
"f6-screen": "gated-person-state-adapter",
|
|
}.get(mode)
|
|
if person_mode is not None:
|
|
train += [
|
|
"--local-fusion", person_mode,
|
|
"--person-attention-dim", "4",
|
|
"--person-attention-heads", "2",
|
|
]
|
|
if mode == "track-gru":
|
|
train += [
|
|
"--person-history-frames", "3",
|
|
"--person-track-max-gap-seconds", "1",
|
|
"--initialize-pooled-checkpoint", str(root / "av.pth"),
|
|
]
|
|
if mode != "baseline":
|
|
modality = (
|
|
"av"
|
|
if mode in ("ego4d", "track-gru", "frame-set", "f5-screen", "f6-screen")
|
|
else mode
|
|
)
|
|
train += ["--local-feature-root", str(root / "local"), "--local-modalities", modality]
|
|
infer += ["--local-feature-root", str(root / "local")]
|
|
for command in (train, infer):
|
|
completed = subprocess.run(command, cwd=repo, env=env, capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(completed.returncode, 0, f"{mode}: {completed.stdout}\n{completed.stderr}")
|
|
metrics = json.loads((root / f"result_{mode}/metrics.json").read_text())
|
|
self.assertEqual(metrics["checkpoint_epoch"], 1)
|
|
self.assertEqual(bool(metrics["local_contract"]), mode != "baseline")
|
|
if mode == "av":
|
|
self.assertTrue(metrics["transition_auxiliary"])
|
|
if person_mode is not None:
|
|
self.assertEqual(metrics["local_fusion"], person_mode)
|
|
self.assertEqual(
|
|
metrics["local_fusion_scope"],
|
|
LOCAL_FUSION_SCOPES[person_mode],
|
|
)
|
|
# The reporting command uses the same metric keys as inference.
|
|
completed = subprocess.run(
|
|
[sys.executable, str(repo / "summarize_local_baselines.py"), "--root", str(root)],
|
|
cwd=repo, capture_output=True, text=True, timeout=20,
|
|
)
|
|
self.assertEqual(completed.returncode, 0, completed.stderr)
|
|
self.assertIn("Mean ± sample standard deviation", completed.stdout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|