Merge branch 'main' of http://182.92.86.220:8300/wx44wx/icassp2027-experiment
This commit is contained in:
@@ -86,6 +86,12 @@ All are pending in that log; Omni LoRA is deferred and no semantic gate is compl
|
|||||||
per cap. CPU tests match separate single-window replay and both head/local
|
per cap. CPU tests match separate single-window replay and both head/local
|
||||||
loaders; real-GPU replay and media smoke remain required. See
|
loaders; real-GPU replay and media smoke remain required. See
|
||||||
[`docs/easycom_multiwindow_features.md`](docs/easycom_multiwindow_features.md).
|
[`docs/easycom_multiwindow_features.md`](docs/easycom_multiwindow_features.md).
|
||||||
|
- Two no-box full-frame Qwen AV screens are implemented on top of the matched
|
||||||
|
global-Omni + recent-Qwen-audio inputs. F2 queries the latest strictly
|
||||||
|
pre-cutoff post-merger spatial-token grid; F3 first applies a shared causal
|
||||||
|
GRU to the latest same-grid-position histories. They use no face boxes or
|
||||||
|
person IDs and therefore test full-frame visual context, not speaker
|
||||||
|
grounding. See [`docs/easycom_fullframe_qwen_av.md`](docs/easycom_fullframe_qwen_av.md).
|
||||||
- Qwen2.5-Omni now has two matched chunk-handling controls around the existing
|
- Qwen2.5-Omni now has two matched chunk-handling controls around the existing
|
||||||
5 Hz partial-tail path: native completed 2-second blocks held between
|
5 Hz partial-tail path: native completed 2-second blocks held between
|
||||||
boundaries, and non-native 200-ms payload commits. Both preserve dense causal
|
boundaries, and non-native 200-ms payload commits. Both preserve dense causal
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ tests.
|
|||||||
| [`easycom_multiwindow_features.md`](easycom_multiwindow_features.md) | Shared-AV extraction of independent Qwen 2/4/6/8/10/12-second context banks, GPU smoke and matched head training |
|
| [`easycom_multiwindow_features.md`](easycom_multiwindow_features.md) | Shared-AV extraction of independent Qwen 2/4/6/8/10/12-second context banks, GPU smoke and matched head training |
|
||||||
| [`local_feature_baseline.md`](local_feature_baseline.md) | Extracting/validating EasyCom pooled local Qwen visual and audio sidecars |
|
| [`local_feature_baseline.md`](local_feature_baseline.md) | Extracting/validating EasyCom pooled local Qwen visual and audio sidecars |
|
||||||
| [`easycom_encoder_only.md`](easycom_encoder_only.md) | Comparing EasyCom Qwen encoder-only A/V/AV representations |
|
| [`easycom_encoder_only.md`](easycom_encoder_only.md) | Comparing EasyCom Qwen encoder-only A/V/AV representations |
|
||||||
|
| [`easycom_fullframe_qwen_av.md`](easycom_fullframe_qwen_av.md) | No-box F2/F3 full-frame Qwen spatial and causal spatiotemporal fusion |
|
||||||
| [`ego4d_encoder_only.md`](ego4d_encoder_only.md) | Preparing and running the Ego4D encoder-only comparison |
|
| [`ego4d_encoder_only.md`](ego4d_encoder_only.md) | Preparing and running the Ego4D encoder-only comparison |
|
||||||
| [`ego4d_local_baselines.md`](ego4d_local_baselines.md) | Previewing Ego4D boxes, extracting local features and running 8-GPU jobs |
|
| [`ego4d_local_baselines.md`](ego4d_local_baselines.md) | Previewing Ego4D boxes, extracting local features and running 8-GPU jobs |
|
||||||
| [`easycom_track_gru_residual.md`](easycom_track_gru_residual.md) | Matched pooled-to-track GRU experiment, causal identity contract and L20 commands |
|
| [`easycom_track_gru_residual.md`](easycom_track_gru_residual.md) | Matched pooled-to-track GRU experiment, causal identity contract and L20 commands |
|
||||||
|
|||||||
143
docs/easycom_fullframe_qwen_av.md
Normal file
143
docs/easycom_fullframe_qwen_av.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# EasyCom no-box full-frame Qwen AV: F2/F3
|
||||||
|
|
||||||
|
## Definition
|
||||||
|
|
||||||
|
This experiment adds full-frame Qwen vision-encoder evidence to the existing
|
||||||
|
strictly causal global Omni plus recent Qwen-audio baseline. It never reads
|
||||||
|
face/head boxes, track IDs, active-speaker labels or speech labels.
|
||||||
|
|
||||||
|
```text
|
||||||
|
F2: G(t) + A(t) query latest full-frame Qwen spatial tokens before t
|
||||||
|
|
||||||
|
F3: same-grid-position tokens from the latest causal frames
|
||||||
|
-> shared chronological GRU
|
||||||
|
-> G(t) + A(t) queries the temporally encoded spatial grid
|
||||||
|
```
|
||||||
|
|
||||||
|
At the default 2 FPS, F3 keeps two observations per grid position: at most the
|
||||||
|
latest one second of visual history. `G` already contains global audio/video
|
||||||
|
context, so this tests whether an explicit recent encoder readout adds timely
|
||||||
|
evidence. It is not an audio-free Omni baseline.
|
||||||
|
|
||||||
|
F2/F3 are no-box full-frame visual-context models. Improvement does not by
|
||||||
|
itself establish speaker grounding, identity or addressee reasoning.
|
||||||
|
|
||||||
|
## Causal contract
|
||||||
|
|
||||||
|
`extract_fullframe_qwen_av_features.py` creates
|
||||||
|
`qwen-fullframe-av-tokens-v1` sidecars:
|
||||||
|
|
||||||
|
- a cutoff `t` sees only fixed-grid frames with timestamp strictly less than
|
||||||
|
`t`; a frame exactly at `t` is excluded;
|
||||||
|
- images are complete EasyCom camera frames after the standard session-banner
|
||||||
|
crop;
|
||||||
|
- Qwen post-merger spatial tokens are stored without mean pooling;
|
||||||
|
- F3 links only the same merged-grid row/column within one source clip, and
|
||||||
|
resets on clip boundaries, grid changes or excessive time gaps;
|
||||||
|
- audio is copied from the existing audited `[t-1,t)` Qwen mixed-audio
|
||||||
|
sidecar; its person-crop tensors are never consumed;
|
||||||
|
- visual/audio model identity, config digest and Transformers version must
|
||||||
|
match exactly;
|
||||||
|
- partial smoke sidecars are rejected by training.
|
||||||
|
|
||||||
|
The targets remain the unchanged 5 Hz `background/wearer/others` labels and
|
||||||
|
the ten 0.2--2.0-second horizons.
|
||||||
|
|
||||||
|
## 1. Four-second GPU smoke extraction
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /data1/yfzx/wangx/Code/icassp2027-experiment
|
||||||
|
|
||||||
|
DATA_ROOT=/data1/yfzx/wangx/Datasets/easycom/extracted_easycom
|
||||||
|
MODEL=/data1/yfzx/wangx/Models/Qwen2.5-Omni-3B
|
||||||
|
FEATURE_ROOT=/data1/yfzx/wangx/Datasets/easycom/omni_features/open_stream_2fps_8s_last4_stack
|
||||||
|
TARGET_ROOT=/data1/yfzx/wangx/Datasets/easycom/easycom_target_frame
|
||||||
|
AUDIO_ROOT=/data1/yfzx/wangx/Datasets/easycom/omni_features/local_qwen_1s_encoded_tf4576
|
||||||
|
SMOKE_ROOT=/data1/yfzx/wangx/Datasets/easycom/omni_features/fullframe_qwen_av_smoke
|
||||||
|
|
||||||
|
CUDA_VISIBLE_DEVICES=0 python extract_fullframe_qwen_av_features.py \
|
||||||
|
--data-root "$DATA_ROOT" --feature-root "$FEATURE_ROOT" \
|
||||||
|
--target-root "$TARGET_ROOT" --audio-feature-root "$AUDIO_ROOT" \
|
||||||
|
--output-root "$SMOKE_ROOT" --samples 4 --max-steps 20 \
|
||||||
|
--model-id "$MODEL" --device cuda:0 --dtype bfloat16 \
|
||||||
|
--flash-attention-2 --video-fps 2 --max-pixels 50176 \
|
||||||
|
--image-batch-size 8
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 20 states spanning 0.2--4.0 s; eight visual frames at
|
||||||
|
`0.0,...,3.5`; finite nonempty spatial tokens; matched 2048D audio; and
|
||||||
|
`partial=True`. Never train on this smoke file.
|
||||||
|
|
||||||
|
If the audio sidecars were made with an explicit `--encoder-id`, pass that
|
||||||
|
exact value here. Otherwise omit it, as above, so both branches use the same
|
||||||
|
recorded local checkpoint path.
|
||||||
|
|
||||||
|
Run the regression tests in the remote environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m unittest -v test_fullframe_qwen_av test_local_turn_features
|
||||||
|
```
|
||||||
|
|
||||||
|
They verify strict boundary exclusion, same-grid F3 history, malformed and
|
||||||
|
partial-contract rejection, and F2/F3 forward/backward behavior.
|
||||||
|
|
||||||
|
## 2. Extract all sessions
|
||||||
|
|
||||||
|
Use a fresh root. Existing files are not overwritten unless `--overwrite` is
|
||||||
|
explicitly provided.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FULLFRAME_ROOT=/data1/yfzx/wangx/Datasets/easycom/omni_features/fullframe_qwen_av_2fps_224
|
||||||
|
|
||||||
|
CUDA_VISIBLE_DEVICES=0 python extract_fullframe_qwen_av_features.py \
|
||||||
|
--data-root "$DATA_ROOT" --feature-root "$FEATURE_ROOT" \
|
||||||
|
--target-root "$TARGET_ROOT" --audio-feature-root "$AUDIO_ROOT" \
|
||||||
|
--output-root "$FULLFRAME_ROOT" \
|
||||||
|
--samples 1 2 3 4 5 6 7 8 9 10 11 12 \
|
||||||
|
--model-id "$MODEL" --device cuda:0 --dtype bfloat16 \
|
||||||
|
--flash-attention-2 --video-fps 2 --max-pixels 50176 \
|
||||||
|
--image-batch-size 8
|
||||||
|
```
|
||||||
|
|
||||||
|
On interruption, rerun only missing session numbers. Do not mix checkpoints,
|
||||||
|
Transformers versions, FPS settings or pixel budgets in one root.
|
||||||
|
|
||||||
|
## 3. Train and evaluate
|
||||||
|
|
||||||
|
The launcher uses Sessions 4--12 for four fixed epochs and evaluates Sessions
|
||||||
|
3/2/1 with the existing LSTR-MCE ten-horizon protocol.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FEATURE_ROOT TARGET_ROOT FULLFRAME_ROOT
|
||||||
|
export CHECKPOINT_ROOT=checkpoints/easycom_fullframe_qwen_av
|
||||||
|
export RESULT_ROOT=results/easycom_fullframe_qwen_av
|
||||||
|
|
||||||
|
CUDA_VISIBLE_DEVICES=0 bash run_easycom_fullframe_qwen_av.sh f2 0
|
||||||
|
CUDA_VISIBLE_DEVICES=1 bash run_easycom_fullframe_qwen_av.sh f3 0
|
||||||
|
```
|
||||||
|
|
||||||
|
After seed 0 passes shape/loss/inference checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CUDA_VISIBLE_DEVICES=0 bash run_easycom_fullframe_qwen_av.sh f2 1 2
|
||||||
|
CUDA_VISIBLE_DEVICES=1 bash run_easycom_fullframe_qwen_av.sh f3 1 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults are train batch 64, inference batch 128, F3 history 2 frames and
|
||||||
|
maximum inter-frame link gap 0.75 s. Lower `TRAIN_BATCH_SIZE` or
|
||||||
|
`INFER_BATCH_SIZE` if needed; do not change the feature contract.
|
||||||
|
|
||||||
|
The checkpoint-compatible option remains named `--person-history-frames`; in
|
||||||
|
F3 it means observations per spatial grid position, not person history.
|
||||||
|
|
||||||
|
## Required comparison and claim boundary
|
||||||
|
|
||||||
|
Compare matched seeds for `G+A`, F2 and F3. Add a past time-shifted full-frame
|
||||||
|
control before attributing gains to AV synchronization. F3 contains a GRU that
|
||||||
|
F2 does not, so F3–F2 combines extra history and temporal capacity; a
|
||||||
|
parameter-matched audio/query temporal control is required before claiming a
|
||||||
|
visual-motion mechanism.
|
||||||
|
|
||||||
|
Dense AP/mAP is an architecture screen. Floor-entry/yield interpretations
|
||||||
|
remain timing-derived proxies until the competing-risk real-data audit is
|
||||||
|
frozen.
|
||||||
134
easycom_head.py
134
easycom_head.py
@@ -67,6 +67,13 @@ CURRENT_FRAME_PERSON_SET_FUSION_MODES = (
|
|||||||
"audio-person-frame-set",
|
"audio-person-frame-set",
|
||||||
"gated-person-state-adapter",
|
"gated-person-state-adapter",
|
||||||
"highrate-person-av-attention",
|
"highrate-person-av-attention",
|
||||||
|
"fullframe-spatial-attention",
|
||||||
|
)
|
||||||
|
FULLFRAME_SPATIAL_FUSION_MODES = ("fullframe-spatial-attention",)
|
||||||
|
FULLFRAME_TEMPORAL_FUSION_MODES = ("fullframe-spatiotemporal-attention",)
|
||||||
|
FULLFRAME_FUSION_MODES = (
|
||||||
|
*FULLFRAME_SPATIAL_FUSION_MODES,
|
||||||
|
*FULLFRAME_TEMPORAL_FUSION_MODES,
|
||||||
)
|
)
|
||||||
HIGHRATE_PERSON_AV_FUSION_MODES = ("highrate-person-av-attention",)
|
HIGHRATE_PERSON_AV_FUSION_MODES = ("highrate-person-av-attention",)
|
||||||
TRACK_PERSON_FUSION_MODES = (
|
TRACK_PERSON_FUSION_MODES = (
|
||||||
@@ -74,12 +81,17 @@ TRACK_PERSON_FUSION_MODES = (
|
|||||||
"causal-person-adapter-v2",
|
"causal-person-adapter-v2",
|
||||||
"causal-person-adapter-egospeak-gru",
|
"causal-person-adapter-egospeak-gru",
|
||||||
)
|
)
|
||||||
|
HISTORY_VISUAL_SET_FUSION_MODES = (
|
||||||
|
*TRACK_PERSON_FUSION_MODES,
|
||||||
|
*FULLFRAME_TEMPORAL_FUSION_MODES,
|
||||||
|
)
|
||||||
PERSON_ADAPTER_V2_MODES = (
|
PERSON_ADAPTER_V2_MODES = (
|
||||||
"causal-person-adapter-v2",
|
"causal-person-adapter-v2",
|
||||||
"causal-person-adapter-egospeak-gru",
|
"causal-person-adapter-egospeak-gru",
|
||||||
)
|
)
|
||||||
PERSON_SET_FUSION_MODES = (
|
PERSON_SET_FUSION_MODES = (
|
||||||
*CURRENT_FRAME_PERSON_SET_FUSION_MODES,
|
*CURRENT_FRAME_PERSON_SET_FUSION_MODES,
|
||||||
|
*FULLFRAME_TEMPORAL_FUSION_MODES,
|
||||||
*TRACK_PERSON_FUSION_MODES,
|
*TRACK_PERSON_FUSION_MODES,
|
||||||
)
|
)
|
||||||
LOCAL_FUSION_MODES = ("pooled", *PERSON_SET_FUSION_MODES)
|
LOCAL_FUSION_MODES = ("pooled", *PERSON_SET_FUSION_MODES)
|
||||||
@@ -92,6 +104,12 @@ LOCAL_FUSION_SCOPES = {
|
|||||||
"highrate-person-av-attention": (
|
"highrate-person-av-attention": (
|
||||||
"global-omni-traditional-audio-base-plus-zero-init-25hz-person-residual"
|
"global-omni-traditional-audio-base-plus-zero-init-25hz-person-residual"
|
||||||
),
|
),
|
||||||
|
"fullframe-spatial-attention": (
|
||||||
|
"f2-global-omni-qwen-audio-latest-fullframe-spatial-tokens"
|
||||||
|
),
|
||||||
|
"fullframe-spatiotemporal-attention": (
|
||||||
|
"f3-global-omni-qwen-audio-causal-fullframe-token-history"
|
||||||
|
),
|
||||||
"track-gru-residual": "causal-anonymous-track-gru-pooled-baseline-residual",
|
"track-gru-residual": "causal-anonymous-track-gru-pooled-baseline-residual",
|
||||||
"causal-person-adapter-v2": (
|
"causal-person-adapter-v2": (
|
||||||
"causal-anonymous-track-gru-dynamic-gated-logit-residual"
|
"causal-anonymous-track-gru-dynamic-gated-logit-residual"
|
||||||
@@ -206,7 +224,10 @@ class TurnPredictionHead(nn.Module):
|
|||||||
fused_size = hidden_size
|
fused_size = hidden_size
|
||||||
projected_modalities = (
|
projected_modalities = (
|
||||||
(("audio", local_audio_dim),)
|
(("audio", local_audio_dim),)
|
||||||
if local_fusion in CURRENT_FRAME_PERSON_SET_FUSION_MODES
|
if local_fusion in (
|
||||||
|
*CURRENT_FRAME_PERSON_SET_FUSION_MODES,
|
||||||
|
*FULLFRAME_TEMPORAL_FUSION_MODES,
|
||||||
|
)
|
||||||
else (("visual", local_visual_dim), ("audio", local_audio_dim))
|
else (("visual", local_visual_dim), ("audio", local_audio_dim))
|
||||||
)
|
)
|
||||||
for name, dimension in projected_modalities:
|
for name, dimension in projected_modalities:
|
||||||
@@ -291,6 +312,13 @@ class TurnPredictionHead(nn.Module):
|
|||||||
batch_first=True,
|
batch_first=True,
|
||||||
)
|
)
|
||||||
self.audio_person_gate = nn.Parameter(torch.zeros(()))
|
self.audio_person_gate = nn.Parameter(torch.zeros(()))
|
||||||
|
if local_fusion in FULLFRAME_TEMPORAL_FUSION_MODES:
|
||||||
|
self.person_track_gru = nn.GRU(
|
||||||
|
person_attention_dim,
|
||||||
|
person_attention_dim,
|
||||||
|
num_layers=1,
|
||||||
|
batch_first=True,
|
||||||
|
)
|
||||||
if local_fusion == "gated-person-state-adapter":
|
if local_fusion == "gated-person-state-adapter":
|
||||||
# Screening version of F6 for precomputed frozen states. It
|
# Screening version of F6 for precomputed frozen states. It
|
||||||
# injects selected person evidence after the Thinker readout,
|
# injects selected person evidence after the Thinker readout,
|
||||||
@@ -303,6 +331,8 @@ class TurnPredictionHead(nn.Module):
|
|||||||
nn.Linear(person_attention_dim, hidden_size),
|
nn.Linear(person_attention_dim, hidden_size),
|
||||||
)
|
)
|
||||||
self.person_state_adapter_gate = nn.Parameter(torch.zeros(()))
|
self.person_state_adapter_gate = nn.Parameter(torch.zeros(()))
|
||||||
|
elif local_fusion in FULLFRAME_TEMPORAL_FUSION_MODES:
|
||||||
|
fused_size += person_attention_dim + 1
|
||||||
elif local_fusion in TRACK_PERSON_FUSION_MODES:
|
elif local_fusion in TRACK_PERSON_FUSION_MODES:
|
||||||
# Apply one shared causal GRU independently to every anonymous
|
# Apply one shared causal GRU independently to every anonymous
|
||||||
# face track. The regular pooled G+A+V head remains the exact
|
# face track. The regular pooled G+A+V head remains the exact
|
||||||
@@ -419,7 +449,10 @@ class TurnPredictionHead(nn.Module):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def local_input_size(self) -> int:
|
def local_input_size(self) -> int:
|
||||||
if self.local_fusion in CURRENT_FRAME_PERSON_SET_FUSION_MODES:
|
if self.local_fusion in (
|
||||||
|
*CURRENT_FRAME_PERSON_SET_FUSION_MODES,
|
||||||
|
*FULLFRAME_TEMPORAL_FUSION_MODES,
|
||||||
|
):
|
||||||
return self.local_audio_dim + 1
|
return self.local_audio_dim + 1
|
||||||
return sum(d + 1 for d in (self.local_visual_dim, self.local_audio_dim) if d)
|
return sum(d + 1 for d in (self.local_visual_dim, self.local_audio_dim) if d)
|
||||||
|
|
||||||
@@ -567,6 +600,65 @@ class TurnPredictionHead(nn.Module):
|
|||||||
audio_valid,
|
audio_valid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _fullframe_temporal_context(
|
||||||
|
self,
|
||||||
|
states: torch.Tensor,
|
||||||
|
local: torch.Tensor,
|
||||||
|
token_histories: torch.Tensor | None,
|
||||||
|
history_mask: torch.Tensor | None,
|
||||||
|
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||||
|
"""Encode same-grid-position visual histories, then query the grid."""
|
||||||
|
|
||||||
|
if states.ndim != 2:
|
||||||
|
raise ValueError("F3 expects batched [B,D] global states")
|
||||||
|
if local.shape != (len(states), self.local_audio_dim + 1):
|
||||||
|
raise ValueError("F3 requires aligned [Qwen audio,valid] features")
|
||||||
|
if token_histories is None or token_histories.ndim != 4:
|
||||||
|
raise ValueError("F3 requires token histories [B,K,L,visual_dim]")
|
||||||
|
if (
|
||||||
|
token_histories.shape[0] != len(states)
|
||||||
|
or token_histories.shape[2] != self.person_history_frames
|
||||||
|
or token_histories.shape[3] != self.local_visual_dim
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"F3 history shape does not match its configured length/dimension"
|
||||||
|
)
|
||||||
|
assert self.person_state_query is not None
|
||||||
|
assert self.person_audio_query is not None
|
||||||
|
assert self.person_attention is not None
|
||||||
|
assert self.null_person is not None
|
||||||
|
token_states, token_valid = self._encode_person_track_histories(
|
||||||
|
token_histories,
|
||||||
|
history_mask,
|
||||||
|
)
|
||||||
|
audio_values = local[:, : self.local_audio_dim]
|
||||||
|
audio_valid = local[:, self.local_audio_dim :]
|
||||||
|
audio_projected = self.local_projections["audio"](audio_values) * audio_valid
|
||||||
|
query = self.person_state_query(states)
|
||||||
|
query = query + self.person_audio_query(audio_values) * audio_valid
|
||||||
|
memory = torch.cat(
|
||||||
|
(self.null_person.expand(len(states), -1, -1), token_states),
|
||||||
|
dim=1,
|
||||||
|
)
|
||||||
|
memory_valid = torch.cat(
|
||||||
|
(
|
||||||
|
torch.ones(
|
||||||
|
(len(states), 1), dtype=torch.bool, device=states.device
|
||||||
|
),
|
||||||
|
token_valid,
|
||||||
|
),
|
||||||
|
dim=1,
|
||||||
|
)
|
||||||
|
context, _ = self.person_attention(
|
||||||
|
query[:, None, :],
|
||||||
|
memory,
|
||||||
|
memory,
|
||||||
|
key_padding_mask=~memory_valid,
|
||||||
|
need_weights=False,
|
||||||
|
)
|
||||||
|
visual_valid = token_valid.any(dim=1, keepdim=True).to(states.dtype)
|
||||||
|
return audio_projected, audio_valid, context[:, 0, :], visual_valid
|
||||||
|
|
||||||
def _encode_person_track_histories(
|
def _encode_person_track_histories(
|
||||||
self,
|
self,
|
||||||
person_histories: torch.Tensor,
|
person_histories: torch.Tensor,
|
||||||
@@ -685,6 +777,21 @@ class TurnPredictionHead(nn.Module):
|
|||||||
(states, audio, audio_valid, person_context, person_valid),
|
(states, audio, audio_valid, person_context, person_valid),
|
||||||
dim=-1,
|
dim=-1,
|
||||||
)
|
)
|
||||||
|
elif self.local_fusion in FULLFRAME_TEMPORAL_FUSION_MODES:
|
||||||
|
if local is None:
|
||||||
|
raise ValueError("F3 requires local Qwen audio features")
|
||||||
|
audio, audio_valid, visual_context, visual_valid = (
|
||||||
|
self._fullframe_temporal_context(
|
||||||
|
states,
|
||||||
|
local,
|
||||||
|
person_features,
|
||||||
|
person_mask,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
states = torch.cat(
|
||||||
|
(states, audio, audio_valid, visual_context, visual_valid),
|
||||||
|
dim=-1,
|
||||||
|
)
|
||||||
elif self.local_input_size:
|
elif self.local_input_size:
|
||||||
if local is None or local.shape != (*states.shape[:-1], self.local_input_size):
|
if local is None or local.shape != (*states.shape[:-1], self.local_input_size):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -1554,7 +1661,26 @@ def load_session_data(
|
|||||||
name,
|
name,
|
||||||
dataset=dataset,
|
dataset=dataset,
|
||||||
)
|
)
|
||||||
if local_fusion in TRACK_PERSON_FUSION_MODES:
|
if local_fusion in FULLFRAME_FUSION_MODES:
|
||||||
|
if dataset != "easycom" or local_modalities != "av":
|
||||||
|
raise ValueError(
|
||||||
|
"Full-frame F2/F3 require EasyCom and --local-modalities av"
|
||||||
|
)
|
||||||
|
from fullframe_qwen_av import load_fullframe_av_features
|
||||||
|
|
||||||
|
local, local_people, contract = load_fullframe_av_features(
|
||||||
|
local_path,
|
||||||
|
payload,
|
||||||
|
sample_id=name,
|
||||||
|
dataset=dataset,
|
||||||
|
expected_contract=local_contract,
|
||||||
|
history_max_gap_seconds=(
|
||||||
|
person_track_max_gap_seconds
|
||||||
|
if local_fusion in FULLFRAME_TEMPORAL_FUSION_MODES
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif local_fusion in TRACK_PERSON_FUSION_MODES:
|
||||||
if local_modalities != "av":
|
if local_modalities != "av":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Track person fusion requires --local-modalities av"
|
"Track person fusion requires --local-modalities av"
|
||||||
@@ -1610,7 +1736,7 @@ def load_session_data(
|
|||||||
elif local_contract is not None:
|
elif local_contract is not None:
|
||||||
raise ValueError("--local-feature-root is required for this checkpoint")
|
raise ValueError("--local-feature-root is required for this checkpoint")
|
||||||
elif local_fusion != "pooled":
|
elif local_fusion != "pooled":
|
||||||
raise ValueError("Person-set fusion requires --local-feature-root")
|
raise ValueError("Set/token fusion requires --local-feature-root")
|
||||||
return SessionData(
|
return SessionData(
|
||||||
name,
|
name,
|
||||||
states,
|
states,
|
||||||
|
|||||||
402
extract_fullframe_qwen_av_features.py
Normal file
402
extract_fullframe_qwen_av_features.py
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
"""Extract causal full-frame Qwen vision tokens plus matched Qwen audio.
|
||||||
|
|
||||||
|
This EasyCom-only extractor never reads face/head boxes or speaker labels. It
|
||||||
|
reuses the audited recent mixed-audio vectors from a local-Qwen sidecar and
|
||||||
|
encodes complete camera frames on a fixed causal grid. The output supports F2
|
||||||
|
latest-frame spatial attention and F3 same-grid-position temporal modeling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from tqdm.auto import tqdm
|
||||||
|
|
||||||
|
from easycom_head import normalize_sample_name, resolve_feature_path
|
||||||
|
from extract_turn_local_features import (
|
||||||
|
model_config_digest,
|
||||||
|
validate_local_transformers_version,
|
||||||
|
validate_source_timeline,
|
||||||
|
)
|
||||||
|
from fullframe_qwen_av import FULLFRAME_AV_FORMAT
|
||||||
|
from local_turn_features import causal_frame_ticks, load_local_features, reference_alignment
|
||||||
|
|
||||||
|
|
||||||
|
class FrozenQwenFullFrameEncoder:
|
||||||
|
"""Keep only the frozen Qwen vision tower and merger on the GPU."""
|
||||||
|
|
||||||
|
def __init__(self, args: argparse.Namespace) -> None:
|
||||||
|
import transformers
|
||||||
|
from transformers import Qwen2_5OmniProcessor, Qwen2_5OmniThinkerForConditionalGeneration
|
||||||
|
|
||||||
|
self.transformers_version = validate_local_transformers_version(transformers.__version__)
|
||||||
|
self.device = torch.device(args.device)
|
||||||
|
if self.device.type == "cuda" and not torch.cuda.is_available():
|
||||||
|
raise RuntimeError(f"CUDA requested but unavailable: {args.device}")
|
||||||
|
dtype_key = "dtype" if self.transformers_version == "4.57.6" else "torch_dtype"
|
||||||
|
kwargs = {
|
||||||
|
dtype_key: getattr(torch, args.dtype),
|
||||||
|
"device_map": {"": "cpu"},
|
||||||
|
"output_loading_info": True,
|
||||||
|
}
|
||||||
|
if args.flash_attention_2:
|
||||||
|
kwargs["attn_implementation"] = "flash_attention_2"
|
||||||
|
model, info = Qwen2_5OmniThinkerForConditionalGeneration.from_pretrained(
|
||||||
|
args.model_id, **kwargs
|
||||||
|
)
|
||||||
|
bad = [key for key in info.get("missing_keys", []) if key.startswith("visual.")]
|
||||||
|
if bad or info.get("mismatched_keys") or info.get("error_msgs"):
|
||||||
|
raise RuntimeError(f"Incomplete Qwen vision weights: {info}")
|
||||||
|
model.requires_grad_(False).eval()
|
||||||
|
self.visual_dim = int(model.config.vision_config.out_hidden_size)
|
||||||
|
self.model_config_sha256 = model_config_digest(model.config.to_dict())
|
||||||
|
model.model = None
|
||||||
|
model.lm_head = None
|
||||||
|
model.audio_tower = None
|
||||||
|
model.visual.to(self.device)
|
||||||
|
self.model = model
|
||||||
|
self.processor = Qwen2_5OmniProcessor.from_pretrained(
|
||||||
|
args.model_id, use_fast=False
|
||||||
|
)
|
||||||
|
self.max_pixels = args.max_pixels
|
||||||
|
self.image_batch_size = args.image_batch_size
|
||||||
|
|
||||||
|
@torch.inference_mode()
|
||||||
|
def image_tokens(
|
||||||
|
self, images: list[Any]
|
||||||
|
) -> tuple[list[torch.Tensor], list[tuple[int, int]]]:
|
||||||
|
pieces: list[torch.Tensor] = []
|
||||||
|
grids: list[tuple[int, int]] = []
|
||||||
|
for start in range(0, len(images), self.image_batch_size):
|
||||||
|
batch = self.processor.image_processor(
|
||||||
|
images=images[start : start + self.image_batch_size],
|
||||||
|
min_pixels=4 * 28 * 28,
|
||||||
|
max_pixels=self.max_pixels,
|
||||||
|
return_tensors="pt",
|
||||||
|
)
|
||||||
|
grid = batch["image_grid_thw"].to(self.device)
|
||||||
|
pixels = batch["pixel_values"].to(
|
||||||
|
self.device, dtype=self.model.visual.dtype
|
||||||
|
)
|
||||||
|
tokens = self.model.get_image_features(pixels, grid)
|
||||||
|
merge = int(self.model.config.vision_config.spatial_merge_size)
|
||||||
|
counts = (grid.prod(-1) // (merge * merge)).tolist()
|
||||||
|
if not isinstance(tokens, torch.Tensor) or tokens.shape != (
|
||||||
|
sum(counts), self.visual_dim
|
||||||
|
):
|
||||||
|
raise RuntimeError(f"Unexpected Qwen image output: {getattr(tokens, 'shape', None)}")
|
||||||
|
pieces.extend(part.float().cpu() for part in tokens.split(counts))
|
||||||
|
for row in grid.tolist():
|
||||||
|
temporal, height, width = (int(value) for value in row)
|
||||||
|
if temporal != 1 or height % merge or width % merge:
|
||||||
|
raise RuntimeError(f"Unexpected image grid before merger: {row}")
|
||||||
|
grids.append((height // merge, width // merge))
|
||||||
|
if len(pieces) != len(images) or len(grids) != len(images):
|
||||||
|
raise AssertionError("Qwen did not return one token set per image")
|
||||||
|
return pieces, grids
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--data-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--feature-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--target-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--audio-feature-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--samples", nargs="+", required=True)
|
||||||
|
parser.add_argument("--model-id", default="Qwen/Qwen2.5-Omni-3B")
|
||||||
|
parser.add_argument("--encoder-id")
|
||||||
|
parser.add_argument("--device", default="cuda:0")
|
||||||
|
parser.add_argument(
|
||||||
|
"--dtype", choices=("float32", "float16", "bfloat16"), default="bfloat16"
|
||||||
|
)
|
||||||
|
parser.add_argument("--flash-attention-2", action="store_true")
|
||||||
|
parser.add_argument("--video-fps", type=float, default=2.0)
|
||||||
|
parser.add_argument("--max-pixels", type=int, default=224 * 224)
|
||||||
|
parser.add_argument("--image-batch-size", type=int, default=8)
|
||||||
|
parser.add_argument(
|
||||||
|
"--decoder-backend",
|
||||||
|
choices=("decord", "torchcodec", "pynvcodec"),
|
||||||
|
default="decord",
|
||||||
|
)
|
||||||
|
parser.add_argument("--decoder-threads", type=int, default=1)
|
||||||
|
parser.add_argument("--decoder-gpu-id", type=int, default=0)
|
||||||
|
parser.add_argument("--max-steps", type=int)
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--overwrite", action="store_true")
|
||||||
|
parser.add_argument("--no-progress", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if not np.isfinite(args.video_fps) or args.video_fps <= 0:
|
||||||
|
parser.error("--video-fps must be finite and positive")
|
||||||
|
if args.max_pixels < 4 * 28 * 28 or args.image_batch_size <= 0:
|
||||||
|
parser.error("Require max-pixels >= 3136 and positive image-batch-size")
|
||||||
|
if args.max_steps is not None and args.max_steps <= 0:
|
||||||
|
parser.error("--max-steps must be positive")
|
||||||
|
protected = {
|
||||||
|
args.feature_root.resolve(),
|
||||||
|
args.audio_feature_root.resolve(),
|
||||||
|
args.target_root.resolve(),
|
||||||
|
}
|
||||||
|
if args.output_root.resolve() in protected:
|
||||||
|
parser.error("--output-root must not overwrite an input root")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def _build_media(args: argparse.Namespace, sample: str, end: float):
|
||||||
|
from streaming_turn_model_directly import CausalSourceAV, _easycom_directory_specs
|
||||||
|
|
||||||
|
specs = _easycom_directory_specs(
|
||||||
|
args.data_root / "Main" / "Video_Compressed" / sample
|
||||||
|
)
|
||||||
|
return CausalSourceAV(
|
||||||
|
specs,
|
||||||
|
output_fps=args.video_fps,
|
||||||
|
required_duration=end,
|
||||||
|
decoder_backend=args.decoder_backend,
|
||||||
|
decoder_threads=args.decoder_threads,
|
||||||
|
decoder_gpu_id=args.decoder_gpu_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _requested_config(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
encoder: FrozenQwenFullFrameEncoder,
|
||||||
|
audio_contract: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
source = audio_contract["config"]
|
||||||
|
if source.get("causal_policy") != "half-open-trailing-window":
|
||||||
|
raise ValueError("Source Qwen audio is not a half-open causal window")
|
||||||
|
if source.get("uses_speech_labels") is not False:
|
||||||
|
raise ValueError("Source Qwen audio must not use speech labels")
|
||||||
|
audio_dim = source.get("audio_dim")
|
||||||
|
if not isinstance(audio_dim, int) or audio_dim <= 0:
|
||||||
|
raise ValueError("Source Qwen audio contract lacks audio_dim")
|
||||||
|
encoder_id = args.encoder_id or args.model_id
|
||||||
|
if source.get("encoder_id") != encoder_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Vision/audio encoder identities differ: {encoder_id!r} vs "
|
||||||
|
f"{source.get('encoder_id')!r}"
|
||||||
|
)
|
||||||
|
if source.get("model_config_sha256") != encoder.model_config_sha256:
|
||||||
|
raise ValueError("Vision/audio Qwen model configs differ")
|
||||||
|
if source.get("transformers") != encoder.transformers_version:
|
||||||
|
raise ValueError("Vision/audio Transformers versions differ")
|
||||||
|
return {
|
||||||
|
"encoder_id": encoder_id,
|
||||||
|
"model_config_sha256": encoder.model_config_sha256,
|
||||||
|
"transformers": encoder.transformers_version,
|
||||||
|
"dtype": args.dtype,
|
||||||
|
"attention": "flash_attention_2" if args.flash_attention_2 else "default",
|
||||||
|
"visual_dim": encoder.visual_dim,
|
||||||
|
"audio_dim": audio_dim,
|
||||||
|
"visual_fps": float(args.video_fps),
|
||||||
|
"max_pixels": int(args.max_pixels),
|
||||||
|
"visual_feature_kind": "qwen-vision-post-merger-spatial-token",
|
||||||
|
"visual_source": "easycom-full-frame-after-session-banner-crop",
|
||||||
|
"visual_pooling": "none-ragged-spatial-tokens",
|
||||||
|
"token_grid_identity": "merged-grid-row-column-within-source-clip",
|
||||||
|
"audio_source": "reused-qwen-local-mixed-audio",
|
||||||
|
"audio_window_seconds": source.get("audio_window_seconds"),
|
||||||
|
"source_audio_contract": audio_contract,
|
||||||
|
"uses_boxes": False,
|
||||||
|
"uses_speech_labels": False,
|
||||||
|
"causal_policy": "strict-half-open-fullframe-grid",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_visual_tokens(
|
||||||
|
media,
|
||||||
|
encoder: FrozenQwenFullFrameEncoder,
|
||||||
|
*,
|
||||||
|
end: float,
|
||||||
|
fps: float,
|
||||||
|
batch_size: int,
|
||||||
|
no_progress: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
ticks = list(causal_frame_ticks(end, fps))
|
||||||
|
frame_times = torch.tensor(ticks, dtype=torch.float64) / fps
|
||||||
|
feature_parts: list[torch.Tensor] = []
|
||||||
|
grid_shapes: list[tuple[int, int]] = []
|
||||||
|
source_clip_indices: list[int] = []
|
||||||
|
offsets = [0]
|
||||||
|
progress = tqdm(
|
||||||
|
range(0, len(ticks), batch_size),
|
||||||
|
desc="full-frame Qwen vision",
|
||||||
|
unit="batch",
|
||||||
|
disable=no_progress,
|
||||||
|
)
|
||||||
|
for start in progress:
|
||||||
|
batch_ticks = ticks[start : start + batch_size]
|
||||||
|
images, sources = [], []
|
||||||
|
for tick in batch_ticks:
|
||||||
|
time = tick / fps
|
||||||
|
source = media._source_frame_for_time(time)
|
||||||
|
images.append(media.frame_at_or_before(time))
|
||||||
|
sources.append(source)
|
||||||
|
token_sets, grids = encoder.image_tokens(images)
|
||||||
|
for tick, tokens, grid, source in zip(batch_ticks, token_sets, grids, sources):
|
||||||
|
if tokens.shape != (grid[0] * grid[1], encoder.visual_dim):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Qwen token/grid mismatch at tick {tick}: "
|
||||||
|
f"{tuple(tokens.shape)} vs {grid}"
|
||||||
|
)
|
||||||
|
if not torch.isfinite(tokens).all():
|
||||||
|
raise RuntimeError(f"Non-finite full-frame tokens at tick {tick}")
|
||||||
|
stored = tokens.to(torch.bfloat16)
|
||||||
|
feature_parts.append(stored)
|
||||||
|
grid_shapes.append(grid)
|
||||||
|
source_clip_indices.append(int(source[0]))
|
||||||
|
offsets.append(offsets[-1] + len(stored))
|
||||||
|
features = (
|
||||||
|
torch.cat(feature_parts, dim=0)
|
||||||
|
if feature_parts
|
||||||
|
else torch.empty(0, encoder.visual_dim, dtype=torch.bfloat16)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"features": features,
|
||||||
|
"frame_times": frame_times,
|
||||||
|
"frame_offsets": torch.tensor(offsets, dtype=torch.long),
|
||||||
|
"grid_shapes": torch.tensor(grid_shapes, dtype=torch.long),
|
||||||
|
"frame_source_clip_indices": torch.tensor(
|
||||||
|
source_clip_indices, dtype=torch.long
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
args = parse_args(argv)
|
||||||
|
samples = [normalize_sample_name(value, "easycom") for value in args.samples]
|
||||||
|
if len(samples) != len(set(samples)):
|
||||||
|
raise ValueError("Duplicate sessions")
|
||||||
|
references, audio_paths = {}, {}
|
||||||
|
for sample in samples:
|
||||||
|
references[sample] = resolve_feature_path(args.feature_root, sample, dataset="easycom")
|
||||||
|
audio_paths[sample] = resolve_feature_path(
|
||||||
|
args.audio_feature_root, sample, dataset="easycom"
|
||||||
|
)
|
||||||
|
for path in (
|
||||||
|
references[sample],
|
||||||
|
audio_paths[sample],
|
||||||
|
args.target_root / f"{sample}.npy",
|
||||||
|
):
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(path)
|
||||||
|
destination = args.output_root / f"{sample}.pt"
|
||||||
|
if destination.exists() and not args.overwrite:
|
||||||
|
raise FileExistsError(
|
||||||
|
f"{destination} exists; select a new root or pass --overwrite"
|
||||||
|
)
|
||||||
|
|
||||||
|
audio_contract: dict[str, Any] | None = None
|
||||||
|
session_steps = {}
|
||||||
|
for sample in samples:
|
||||||
|
reference = torch.load(references[sample], map_location="cpu", weights_only=False)
|
||||||
|
times, _ = reference_alignment(reference)
|
||||||
|
target = np.load(
|
||||||
|
args.target_root / f"{sample}.npy", allow_pickle=False, mmap_mode="r"
|
||||||
|
)
|
||||||
|
if target.shape != (len(times), 3):
|
||||||
|
raise ValueError(f"Reference/target mismatch for {sample}")
|
||||||
|
packed_audio, audio_contract = load_local_features(
|
||||||
|
audio_paths[sample],
|
||||||
|
reference,
|
||||||
|
sample_id=sample,
|
||||||
|
dataset="easycom",
|
||||||
|
modalities="audio",
|
||||||
|
expected_contract=audio_contract,
|
||||||
|
)
|
||||||
|
session_steps[sample] = len(times)
|
||||||
|
steps = min(len(times), args.max_steps or len(times))
|
||||||
|
print(
|
||||||
|
f"{sample}: {steps} states, {steps / 5:g}s; "
|
||||||
|
f"matched audio={packed_audio.shape[1] - 1}D",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
del reference, target, packed_audio
|
||||||
|
if args.dry_run:
|
||||||
|
print("Dry run only: source video decode and Qwen vision were not checked.")
|
||||||
|
return
|
||||||
|
|
||||||
|
args.output_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
encoder = FrozenQwenFullFrameEncoder(args)
|
||||||
|
assert audio_contract is not None
|
||||||
|
config = _requested_config(args, encoder, audio_contract)
|
||||||
|
for position, sample in enumerate(samples, 1):
|
||||||
|
print(f"[{position}/{len(samples)}] {sample}", flush=True)
|
||||||
|
reference = torch.load(references[sample], map_location="cpu", weights_only=False)
|
||||||
|
full_times, full_indices = reference_alignment(reference)
|
||||||
|
packed_audio, current_contract = load_local_features(
|
||||||
|
audio_paths[sample],
|
||||||
|
reference,
|
||||||
|
sample_id=sample,
|
||||||
|
dataset="easycom",
|
||||||
|
modalities="audio",
|
||||||
|
expected_contract=audio_contract,
|
||||||
|
)
|
||||||
|
if current_contract != audio_contract:
|
||||||
|
raise AssertionError("Audio contract changed after preflight")
|
||||||
|
full_steps = session_steps[sample]
|
||||||
|
steps = min(full_steps, args.max_steps or full_steps)
|
||||||
|
times, indices, packed_audio = (
|
||||||
|
full_times[:steps],
|
||||||
|
full_indices[:steps],
|
||||||
|
packed_audio[:steps],
|
||||||
|
)
|
||||||
|
end = steps / 5
|
||||||
|
media = _build_media(args, sample, end)
|
||||||
|
validate_source_timeline(reference, media)
|
||||||
|
visual_tokens = _extract_visual_tokens(
|
||||||
|
media,
|
||||||
|
encoder,
|
||||||
|
end=end,
|
||||||
|
fps=args.video_fps,
|
||||||
|
batch_size=args.image_batch_size,
|
||||||
|
no_progress=args.no_progress,
|
||||||
|
)
|
||||||
|
audio = packed_audio[:, :-1].to(torch.bfloat16)
|
||||||
|
audio_valid = packed_audio[:, -1].bool()
|
||||||
|
if torch.count_nonzero(audio[~audio_valid]):
|
||||||
|
raise ValueError("Invalid inherited audio rows are not zero")
|
||||||
|
output = {
|
||||||
|
"format": FULLFRAME_AV_FORMAT,
|
||||||
|
"dataset": "easycom",
|
||||||
|
"sample_id": sample,
|
||||||
|
"times": times,
|
||||||
|
"label_indices": indices,
|
||||||
|
"partial": steps != full_steps,
|
||||||
|
"audio": audio,
|
||||||
|
"audio_valid": audio_valid,
|
||||||
|
"visual_tokens": visual_tokens,
|
||||||
|
"config": config,
|
||||||
|
"reference_features": str(references[sample].resolve()),
|
||||||
|
"source_audio_features": str(audio_paths[sample].resolve()),
|
||||||
|
"direct_media": media.metadata(),
|
||||||
|
}
|
||||||
|
destination = args.output_root / f"{sample}.pt"
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
dir=args.output_root,
|
||||||
|
prefix=f".{sample}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
) as handle:
|
||||||
|
temporary = Path(handle.name)
|
||||||
|
try:
|
||||||
|
torch.save(output, temporary)
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
print(
|
||||||
|
f"Saved {destination}: frames={len(visual_tokens['frame_times'])}, "
|
||||||
|
f"tokens={len(visual_tokens['features'])}, partial={output['partial']}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
192
fullframe_qwen_av.py
Normal file
192
fullframe_qwen_av.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""Strict no-box full-frame Qwen AV sidecar contract and loader."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from local_turn_features import FramePersonFeatures, reference_alignment
|
||||||
|
|
||||||
|
|
||||||
|
FULLFRAME_AV_FORMAT = "qwen-fullframe-av-tokens-v1"
|
||||||
|
|
||||||
|
|
||||||
|
def load_fullframe_av_features(
|
||||||
|
path: Path,
|
||||||
|
reference: dict[str, Any],
|
||||||
|
*,
|
||||||
|
sample_id: str,
|
||||||
|
dataset: str,
|
||||||
|
expected_contract: dict[str, Any] | None = None,
|
||||||
|
history_max_gap_seconds: float | None = None,
|
||||||
|
) -> tuple[torch.Tensor, FramePersonFeatures, dict[str, Any]]:
|
||||||
|
"""Load aligned Qwen audio and ragged full-frame spatial token sets."""
|
||||||
|
|
||||||
|
times, indices = reference_alignment(reference)
|
||||||
|
payload = torch.load(path, map_location="cpu", weights_only=False)
|
||||||
|
if not isinstance(payload, dict) or payload.get("format") != FULLFRAME_AV_FORMAT:
|
||||||
|
raise ValueError(f"Invalid full-frame feature format: {path}")
|
||||||
|
if payload.get("sample_id") != sample_id or payload.get("dataset") != dataset:
|
||||||
|
raise ValueError(f"Full-frame sample/dataset mismatch: {path}")
|
||||||
|
if dataset != "easycom":
|
||||||
|
raise ValueError("Full-frame F2/F3 currently support EasyCom only")
|
||||||
|
if payload.get("partial", False):
|
||||||
|
raise ValueError(f"Partial smoke-test features cannot be used for training: {path}")
|
||||||
|
local_indices = torch.as_tensor(payload.get("label_indices", []))
|
||||||
|
local_times = torch.as_tensor(payload.get("times", []), dtype=torch.float64)
|
||||||
|
if local_indices.dtype != torch.long or not torch.equal(indices, local_indices):
|
||||||
|
raise ValueError(f"Full-frame label_indices mismatch: {path}")
|
||||||
|
if local_times.shape != times.shape or not torch.allclose(
|
||||||
|
local_times, times, atol=1e-7, rtol=0
|
||||||
|
):
|
||||||
|
raise ValueError(f"Full-frame times mismatch: {path}")
|
||||||
|
config = payload.get("config", {})
|
||||||
|
if (
|
||||||
|
config.get("causal_policy") != "strict-half-open-fullframe-grid"
|
||||||
|
or config.get("uses_speech_labels") is not False
|
||||||
|
or config.get("uses_boxes") is not False
|
||||||
|
or config.get("visual_feature_kind")
|
||||||
|
!= "qwen-vision-post-merger-spatial-token"
|
||||||
|
or config.get("visual_source")
|
||||||
|
!= "easycom-full-frame-after-session-banner-crop"
|
||||||
|
or config.get("audio_source") != "reused-qwen-local-mixed-audio"
|
||||||
|
):
|
||||||
|
raise ValueError(f"Full-frame AV features lack the causal/no-box contract: {path}")
|
||||||
|
contract = {"format": FULLFRAME_AV_FORMAT, "modalities": "av", "config": config}
|
||||||
|
if expected_contract is not None and contract != expected_contract:
|
||||||
|
raise ValueError(f"Full-frame contract differs across sessions/checkpoint: {path}")
|
||||||
|
|
||||||
|
audio = payload.get("audio")
|
||||||
|
audio_valid = payload.get("audio_valid")
|
||||||
|
audio_dim = config.get("audio_dim")
|
||||||
|
if not isinstance(audio_dim, int) or audio_dim <= 0:
|
||||||
|
raise ValueError(f"Missing audio_dim: {path}")
|
||||||
|
if (
|
||||||
|
not isinstance(audio, torch.Tensor)
|
||||||
|
or audio.shape != (len(times), audio_dim)
|
||||||
|
or not audio.is_floating_point()
|
||||||
|
or not torch.isfinite(audio).all()
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid full-frame sidecar audio: {path}")
|
||||||
|
if (
|
||||||
|
not isinstance(audio_valid, torch.Tensor)
|
||||||
|
or audio_valid.dtype != torch.bool
|
||||||
|
or audio_valid.shape != (len(times),)
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid full-frame audio_valid mask: {path}")
|
||||||
|
if torch.count_nonzero(audio[~audio_valid]):
|
||||||
|
raise ValueError(f"Invalid audio rows must be stored as zeros: {path}")
|
||||||
|
|
||||||
|
visual_dim = config.get("visual_dim")
|
||||||
|
visual_fps = config.get("visual_fps")
|
||||||
|
tokens = payload.get("visual_tokens")
|
||||||
|
if not isinstance(visual_dim, int) or visual_dim <= 0:
|
||||||
|
raise ValueError(f"Missing visual_dim: {path}")
|
||||||
|
if not isinstance(visual_fps, (int, float)) or not math.isfinite(visual_fps) or visual_fps <= 0:
|
||||||
|
raise ValueError(f"Invalid visual_fps: {path}")
|
||||||
|
if not isinstance(tokens, dict):
|
||||||
|
raise ValueError(f"Missing full-frame visual tokens: {path}")
|
||||||
|
features = tokens.get("features")
|
||||||
|
frame_times = tokens.get("frame_times")
|
||||||
|
frame_offsets = tokens.get("frame_offsets")
|
||||||
|
grid_shapes = tokens.get("grid_shapes")
|
||||||
|
source_clip_indices = tokens.get("frame_source_clip_indices")
|
||||||
|
if (
|
||||||
|
not isinstance(features, torch.Tensor)
|
||||||
|
or features.ndim != 2
|
||||||
|
or features.shape[1] != visual_dim
|
||||||
|
or not features.is_floating_point()
|
||||||
|
or not torch.isfinite(features).all()
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid full-frame token features: {path}")
|
||||||
|
if (
|
||||||
|
not isinstance(frame_times, torch.Tensor)
|
||||||
|
or frame_times.ndim != 1
|
||||||
|
or not frame_times.is_floating_point()
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid full-frame frame_times: {path}")
|
||||||
|
frame_times = frame_times.to(dtype=torch.float64)
|
||||||
|
if (
|
||||||
|
not len(frame_times)
|
||||||
|
or not torch.isfinite(frame_times).all()
|
||||||
|
or bool((frame_times < 0).any())
|
||||||
|
or bool((frame_times[1:] <= frame_times[:-1]).any())
|
||||||
|
):
|
||||||
|
raise ValueError(f"Full-frame times must be finite/increasing: {path}")
|
||||||
|
expected_frame_count = max(
|
||||||
|
0,
|
||||||
|
math.ceil(float(times[-1]) * float(visual_fps) - 1e-8),
|
||||||
|
)
|
||||||
|
expected_frame_times = (
|
||||||
|
torch.arange(expected_frame_count, dtype=torch.float64)
|
||||||
|
/ float(visual_fps)
|
||||||
|
)
|
||||||
|
if frame_times.shape != expected_frame_times.shape or not torch.allclose(
|
||||||
|
frame_times, expected_frame_times, atol=1e-8, rtol=0
|
||||||
|
):
|
||||||
|
raise ValueError(f"Full-frame times are not the fixed causal grid: {path}")
|
||||||
|
if (
|
||||||
|
not isinstance(frame_offsets, torch.Tensor)
|
||||||
|
or frame_offsets.dtype != torch.long
|
||||||
|
or frame_offsets.shape != (len(frame_times) + 1,)
|
||||||
|
or int(frame_offsets[0]) != 0
|
||||||
|
or int(frame_offsets[-1]) != len(features)
|
||||||
|
or bool((frame_offsets[1:] <= frame_offsets[:-1]).any())
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid full-frame frame_offsets: {path}")
|
||||||
|
if (
|
||||||
|
not isinstance(grid_shapes, torch.Tensor)
|
||||||
|
or grid_shapes.dtype != torch.long
|
||||||
|
or grid_shapes.shape != (len(frame_times), 2)
|
||||||
|
or bool((grid_shapes <= 0).any())
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid full-frame grid_shapes: {path}")
|
||||||
|
if not torch.equal(frame_offsets[1:] - frame_offsets[:-1], grid_shapes.prod(-1)):
|
||||||
|
raise ValueError(f"Token counts do not match full-frame grids: {path}")
|
||||||
|
if (
|
||||||
|
not isinstance(source_clip_indices, torch.Tensor)
|
||||||
|
or source_clip_indices.dtype != torch.long
|
||||||
|
or source_clip_indices.shape != (len(frame_times),)
|
||||||
|
or bool((source_clip_indices < 0).any())
|
||||||
|
or bool((source_clip_indices[1:] < source_clip_indices[:-1]).any())
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid full-frame source-clip indices: {path}")
|
||||||
|
if float(frame_times[-1]) >= float(times[-1]) - 1e-8:
|
||||||
|
raise ValueError(f"Full-frame timeline extends past the reference cutoff: {path}")
|
||||||
|
|
||||||
|
row_to_frame = torch.searchsorted(frame_times, times, right=False) - 1
|
||||||
|
chosen = torch.full(times.shape, float("nan"), dtype=torch.float64)
|
||||||
|
valid_rows = row_to_frame >= 0
|
||||||
|
chosen[valid_rows] = frame_times[row_to_frame[valid_rows]]
|
||||||
|
if bool((chosen[valid_rows] >= times[valid_rows]).any()):
|
||||||
|
raise ValueError(f"Full-frame mapping selected boundary/future evidence: {path}")
|
||||||
|
|
||||||
|
previous = None
|
||||||
|
if history_max_gap_seconds is not None:
|
||||||
|
if not math.isfinite(history_max_gap_seconds) or history_max_gap_seconds <= 0:
|
||||||
|
raise ValueError("history_max_gap_seconds must be finite and positive")
|
||||||
|
previous = torch.full((len(features),), -1, dtype=torch.long)
|
||||||
|
for frame in range(1, len(frame_times)):
|
||||||
|
gap = float(frame_times[frame] - frame_times[frame - 1])
|
||||||
|
same_clip = bool(source_clip_indices[frame] == source_clip_indices[frame - 1])
|
||||||
|
same_grid = torch.equal(grid_shapes[frame], grid_shapes[frame - 1])
|
||||||
|
if not same_clip or not same_grid or gap > history_max_gap_seconds + 1e-8:
|
||||||
|
continue
|
||||||
|
current_start, current_end = int(frame_offsets[frame]), int(frame_offsets[frame + 1])
|
||||||
|
old_start, old_end = int(frame_offsets[frame - 1]), int(frame_offsets[frame])
|
||||||
|
if current_end - current_start != old_end - old_start:
|
||||||
|
raise AssertionError("Equal grids have unequal token counts")
|
||||||
|
previous[current_start:current_end] = torch.arange(old_start, old_end)
|
||||||
|
|
||||||
|
frames = FramePersonFeatures(
|
||||||
|
features,
|
||||||
|
frame_offsets,
|
||||||
|
row_to_frame.to(torch.long),
|
||||||
|
chosen,
|
||||||
|
previous,
|
||||||
|
)
|
||||||
|
packed_audio = torch.cat((audio.float(), audio_valid[:, None].float()), dim=-1)
|
||||||
|
return packed_audio, frames, contract
|
||||||
@@ -14,12 +14,12 @@ from tqdm.auto import tqdm
|
|||||||
from easycom_head import (
|
from easycom_head import (
|
||||||
CLASS_NAMES,
|
CLASS_NAMES,
|
||||||
DATASET_NAMES,
|
DATASET_NAMES,
|
||||||
|
HISTORY_VISUAL_SET_FUSION_MODES,
|
||||||
LOCAL_FUSION_SCOPES,
|
LOCAL_FUSION_SCOPES,
|
||||||
NUM_CLASSES,
|
NUM_CLASSES,
|
||||||
NUM_HORIZONS,
|
NUM_HORIZONS,
|
||||||
OFFICIAL_TEST_SESSIONS,
|
OFFICIAL_TEST_SESSIONS,
|
||||||
STEP_SECONDS,
|
STEP_SECONDS,
|
||||||
TRACK_PERSON_FUSION_MODES,
|
|
||||||
TurnPredictionHead,
|
TurnPredictionHead,
|
||||||
average_precision_metrics,
|
average_precision_metrics,
|
||||||
checkpoint_head_config,
|
checkpoint_head_config,
|
||||||
@@ -150,7 +150,7 @@ def _predict_states(
|
|||||||
start,
|
start,
|
||||||
min(start + batch_size, len(states)),
|
min(start + batch_size, len(states)),
|
||||||
)
|
)
|
||||||
if head.local_fusion in TRACK_PERSON_FUSION_MODES:
|
if head.local_fusion in HISTORY_VISUAL_SET_FUSION_MODES:
|
||||||
person_batch, person_mask = local_person_features.padded_history(
|
person_batch, person_mask = local_person_features.padded_history(
|
||||||
rows,
|
rows,
|
||||||
max_observations=head.person_history_frames,
|
max_observations=head.person_history_frames,
|
||||||
|
|||||||
65
run_easycom_fullframe_qwen_av.sh
Executable file
65
run_easycom_fullframe_qwen_av.sh
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Foreground only. Usage: bash run_easycom_fullframe_qwen_av.sh [all|f2|f3] [seed ...]
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
FEATURE_ROOT=${FEATURE_ROOT:-/data1/yfzx/wangx/Datasets/easycom/omni_features/open_stream_2fps_8s_last4_stack}
|
||||||
|
TARGET_ROOT=${TARGET_ROOT:-/data1/yfzx/wangx/Datasets/easycom/easycom_target_frame}
|
||||||
|
FULLFRAME_ROOT=${FULLFRAME_ROOT:-/data1/yfzx/wangx/Datasets/easycom/omni_features/fullframe_qwen_av_2fps_224}
|
||||||
|
CHECKPOINT_ROOT=${CHECKPOINT_ROOT:-checkpoints/easycom_fullframe_qwen_av}
|
||||||
|
RESULT_ROOT=${RESULT_ROOT:-results/easycom_fullframe_qwen_av}
|
||||||
|
LOCAL_PROJECTION_DIM=${LOCAL_PROJECTION_DIM:-256}
|
||||||
|
ATTENTION_DIM=${ATTENTION_DIM:-256}
|
||||||
|
ATTENTION_HEADS=${ATTENTION_HEADS:-4}
|
||||||
|
HISTORY_FRAMES=${HISTORY_FRAMES:-2}
|
||||||
|
HISTORY_MAX_GAP_SECONDS=${HISTORY_MAX_GAP_SECONDS:-0.75}
|
||||||
|
TRAIN_BATCH_SIZE=${TRAIN_BATCH_SIZE:-64}
|
||||||
|
INFER_BATCH_SIZE=${INFER_BATCH_SIZE:-128}
|
||||||
|
export OMP_NUM_THREADS=${OMP_NUM_THREADS:-4}
|
||||||
|
export MKL_NUM_THREADS=${MKL_NUM_THREADS:-4}
|
||||||
|
export OPENBLAS_NUM_THREADS=${OPENBLAS_NUM_THREADS:-4}
|
||||||
|
|
||||||
|
variant=${1:-all}
|
||||||
|
if (( $# )); then shift; fi
|
||||||
|
if (( $# )); then seeds=("$@"); else seeds=(0 1 2); fi
|
||||||
|
case "$variant" in
|
||||||
|
all) variants=(f2 f3) ;;
|
||||||
|
f2|f3) variants=("$variant") ;;
|
||||||
|
*) echo "Expected all, f2, or f3" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
for variant in "${variants[@]}"; do
|
||||||
|
case "$variant" in
|
||||||
|
f2) fusion=fullframe-spatial-attention ;;
|
||||||
|
f3) fusion=fullframe-spatiotemporal-attention ;;
|
||||||
|
esac
|
||||||
|
for seed in "${seeds[@]}"; do
|
||||||
|
checkpoint="$CHECKPOINT_ROOT/${variant}_seed${seed}.pth"
|
||||||
|
result="$RESULT_ROOT/${variant}_seed${seed}"
|
||||||
|
if [[ -e "$checkpoint" || -e "$result" ]]; then
|
||||||
|
echo "Refusing to overwrite $checkpoint or $result; select new output roots." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python train_turn_head.py \
|
||||||
|
--feature-root "$FEATURE_ROOT" --target-root "$TARGET_ROOT" \
|
||||||
|
--local-feature-root "$FULLFRAME_ROOT" --local-modalities av \
|
||||||
|
--local-fusion "$fusion" \
|
||||||
|
--local-projection-dim "$LOCAL_PROJECTION_DIM" \
|
||||||
|
--person-attention-dim "$ATTENTION_DIM" \
|
||||||
|
--person-attention-heads "$ATTENTION_HEADS" \
|
||||||
|
--person-history-frames "$HISTORY_FRAMES" \
|
||||||
|
--person-track-max-gap-seconds "$HISTORY_MAX_GAP_SECONDS" \
|
||||||
|
--output "$checkpoint" --sessions 4 5 6 7 8 9 10 11 12 \
|
||||||
|
--epochs 4 --batch-size "$TRAIN_BATCH_SIZE" \
|
||||||
|
--learning-rate 7e-5 --weight-decay 5e-5 \
|
||||||
|
--warmup-epochs 1.6 --warmup-factor 0.3 --dropout 0.2 \
|
||||||
|
--loss lstr-mce --transition-objective none --layer-fusion concat-mlp \
|
||||||
|
--expected-mode open-stream-replay --seed "$seed" --device cuda:0
|
||||||
|
python infer_turn_head.py \
|
||||||
|
--feature-root "$FEATURE_ROOT" --target-root "$TARGET_ROOT" \
|
||||||
|
--local-feature-root "$FULLFRAME_ROOT" \
|
||||||
|
--checkpoint "$checkpoint" --output-dir "$result" --sessions 3 2 1 \
|
||||||
|
--batch-size "$INFER_BATCH_SIZE" \
|
||||||
|
--expected-mode open-stream-replay --device cuda:0
|
||||||
|
done
|
||||||
|
done
|
||||||
196
test_fullframe_qwen_av.py
Normal file
196
test_fullframe_qwen_av.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""CPU contract and head tests for no-box full-frame F2/F3."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from easycom_head import TurnPredictionHead, checkpoint_head_config, load_session_data
|
||||||
|
from fullframe_qwen_av import FULLFRAME_AV_FORMAT, load_fullframe_av_features
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture(*, partial: bool = False):
|
||||||
|
count = 6
|
||||||
|
times = (torch.arange(count, dtype=torch.float64) + 1) / 5
|
||||||
|
reference = {
|
||||||
|
"states": torch.randn(count, 2, 8),
|
||||||
|
"times": times,
|
||||||
|
"label_indices": torch.arange(count),
|
||||||
|
"start": 0,
|
||||||
|
"mode": "open-stream-replay",
|
||||||
|
}
|
||||||
|
sidecar = {
|
||||||
|
"format": FULLFRAME_AV_FORMAT,
|
||||||
|
"dataset": "easycom",
|
||||||
|
"sample_id": "Session_4",
|
||||||
|
"times": times.clone(),
|
||||||
|
"label_indices": torch.arange(count),
|
||||||
|
"partial": partial,
|
||||||
|
"audio": torch.randn(count, 5),
|
||||||
|
"audio_valid": torch.ones(count, dtype=torch.bool),
|
||||||
|
"visual_tokens": {
|
||||||
|
"features": torch.arange(12 * 6, dtype=torch.float32).reshape(12, 6),
|
||||||
|
"frame_times": torch.tensor([0.0, 0.5, 1.0], dtype=torch.float64),
|
||||||
|
"frame_offsets": torch.tensor([0, 4, 8, 12], dtype=torch.long),
|
||||||
|
"grid_shapes": torch.tensor([[2, 2], [2, 2], [2, 2]], dtype=torch.long),
|
||||||
|
"frame_source_clip_indices": torch.zeros(3, dtype=torch.long),
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"visual_dim": 6,
|
||||||
|
"audio_dim": 5,
|
||||||
|
"visual_fps": 2.0,
|
||||||
|
"causal_policy": "strict-half-open-fullframe-grid",
|
||||||
|
"uses_speech_labels": False,
|
||||||
|
"uses_boxes": False,
|
||||||
|
"visual_feature_kind": "qwen-vision-post-merger-spatial-token",
|
||||||
|
"visual_source": "easycom-full-frame-after-session-banner-crop",
|
||||||
|
"audio_source": "reused-qwen-local-mixed-audio",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return reference, sidecar
|
||||||
|
|
||||||
|
|
||||||
|
class FullFrameQwenAVTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
torch.set_num_threads(1)
|
||||||
|
|
||||||
|
def _load(self, sidecar, reference, *, history=False):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "Session_4.pt"
|
||||||
|
torch.save(sidecar, path)
|
||||||
|
return load_fullframe_av_features(
|
||||||
|
path,
|
||||||
|
reference,
|
||||||
|
sample_id="Session_4",
|
||||||
|
dataset="easycom",
|
||||||
|
history_max_gap_seconds=0.75 if history else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_strict_boundary_mapping_and_same_grid_history(self):
|
||||||
|
reference, sidecar = _fixture()
|
||||||
|
audio, frames, contract = self._load(sidecar, reference, history=True)
|
||||||
|
self.assertEqual(audio.shape, (6, 6))
|
||||||
|
self.assertEqual(frames.row_to_frame.tolist(), [0, 0, 1, 1, 1, 2])
|
||||||
|
self.assertEqual(
|
||||||
|
frames.chosen_frame_times.tolist(), [0.0, 0.0, 0.5, 0.5, 0.5, 1.0]
|
||||||
|
)
|
||||||
|
self.assertEqual(contract["format"], FULLFRAME_AV_FORMAT)
|
||||||
|
histories, mask = frames.padded_history(
|
||||||
|
torch.tensor([5]), max_observations=2
|
||||||
|
)
|
||||||
|
self.assertEqual(histories.shape, (1, 4, 2, 6))
|
||||||
|
self.assertTrue(mask.all())
|
||||||
|
tokens = sidecar["visual_tokens"]["features"]
|
||||||
|
self.assertTrue(torch.equal(histories[0, 0, 0], tokens[4]))
|
||||||
|
self.assertTrue(torch.equal(histories[0, 0, 1], tokens[8]))
|
||||||
|
|
||||||
|
def test_rejects_partial_and_malformed_contracts(self):
|
||||||
|
reference, sidecar = _fixture(partial=True)
|
||||||
|
with self.assertRaisesRegex(ValueError, "Partial smoke-test"):
|
||||||
|
self._load(sidecar, reference)
|
||||||
|
|
||||||
|
_, sidecar = _fixture()
|
||||||
|
wrong = copy.deepcopy(sidecar)
|
||||||
|
wrong["config"]["uses_boxes"] = True
|
||||||
|
with self.assertRaisesRegex(ValueError, "causal/no-box"):
|
||||||
|
self._load(wrong, reference)
|
||||||
|
|
||||||
|
future = copy.deepcopy(sidecar)
|
||||||
|
future["visual_tokens"]["frame_times"][-1] = reference["times"][-1] + 0.01
|
||||||
|
with self.assertRaisesRegex(ValueError, "fixed causal grid|extends past"):
|
||||||
|
self._load(future, reference)
|
||||||
|
|
||||||
|
def test_f2_f3_forward_backward_and_checkpoint_config(self):
|
||||||
|
reference, sidecar = _fixture()
|
||||||
|
local, frames, _ = self._load(sidecar, reference, history=True)
|
||||||
|
rows = torch.tensor([0, 2, 3])
|
||||||
|
states = torch.randn(3, 8)
|
||||||
|
|
||||||
|
tokens, token_mask = frames.padded(rows)
|
||||||
|
f2 = TurnPredictionHead(
|
||||||
|
hidden_size=8,
|
||||||
|
local_visual_dim=6,
|
||||||
|
local_audio_dim=5,
|
||||||
|
local_projection_dim=4,
|
||||||
|
local_fusion="fullframe-spatial-attention",
|
||||||
|
person_attention_dim=4,
|
||||||
|
person_attention_heads=2,
|
||||||
|
person_history_frames=2,
|
||||||
|
)
|
||||||
|
logits = f2(states, local[rows], tokens, token_mask)
|
||||||
|
self.assertEqual(logits.shape, (3, 10, 3))
|
||||||
|
logits.sum().backward()
|
||||||
|
self.assertIsNotNone(f2.person_attention.in_proj_weight.grad)
|
||||||
|
|
||||||
|
histories, history_mask = frames.padded_history(rows, max_observations=2)
|
||||||
|
f3 = TurnPredictionHead(
|
||||||
|
hidden_size=8,
|
||||||
|
local_visual_dim=6,
|
||||||
|
local_audio_dim=5,
|
||||||
|
local_projection_dim=4,
|
||||||
|
local_fusion="fullframe-spatiotemporal-attention",
|
||||||
|
person_attention_dim=4,
|
||||||
|
person_attention_heads=2,
|
||||||
|
person_history_frames=2,
|
||||||
|
)
|
||||||
|
logits = f3(states, local[rows], histories, history_mask)
|
||||||
|
self.assertEqual(logits.shape, (3, 10, 3))
|
||||||
|
logits.sum().backward()
|
||||||
|
self.assertIsNotNone(f3.person_track_gru.weight_ih_l0.grad)
|
||||||
|
config = checkpoint_head_config(
|
||||||
|
{
|
||||||
|
"head_config": {
|
||||||
|
"hidden_size": 8,
|
||||||
|
"local_visual_dim": 6,
|
||||||
|
"local_audio_dim": 5,
|
||||||
|
"local_fusion": "fullframe-spatiotemporal-attention",
|
||||||
|
"person_history_frames": 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(config["local_fusion"], "fullframe-spatiotemporal-attention")
|
||||||
|
self.assertEqual(config["person_history_frames"], 2)
|
||||||
|
|
||||||
|
def test_session_loader_routes_fullframe_contract(self):
|
||||||
|
reference, sidecar = _fixture()
|
||||||
|
labels = np.tile(
|
||||||
|
np.asarray(
|
||||||
|
[[1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.float32
|
||||||
|
),
|
||||||
|
(2, 1),
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
feature_root = root / "global"
|
||||||
|
target_root = root / "target"
|
||||||
|
local_root = root / "fullframe"
|
||||||
|
for folder in (feature_root, target_root, local_root):
|
||||||
|
folder.mkdir()
|
||||||
|
torch.save(reference, feature_root / "Session_4.pt")
|
||||||
|
torch.save(sidecar, local_root / "Session_4.pt")
|
||||||
|
np.save(target_root / "Session_4.npy", labels)
|
||||||
|
data = load_session_data(
|
||||||
|
feature_root,
|
||||||
|
target_root,
|
||||||
|
"Session_4",
|
||||||
|
expected_mode="open-stream-replay",
|
||||||
|
local_feature_root=local_root,
|
||||||
|
local_modalities="av",
|
||||||
|
local_fusion="fullframe-spatiotemporal-attention",
|
||||||
|
person_track_max_gap_seconds=0.75,
|
||||||
|
)
|
||||||
|
self.assertEqual(data.local_features.shape, (6, 6))
|
||||||
|
self.assertIsNotNone(data.local_person_features.previous_observation)
|
||||||
|
self.assertEqual(
|
||||||
|
data.feature_metadata["local_contract"]["format"],
|
||||||
|
FULLFRAME_AV_FORMAT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -24,6 +24,8 @@ from tqdm.auto import tqdm
|
|||||||
from easycom_head import (
|
from easycom_head import (
|
||||||
CLASS_NAMES,
|
CLASS_NAMES,
|
||||||
DATASET_NAMES,
|
DATASET_NAMES,
|
||||||
|
FULLFRAME_FUSION_MODES,
|
||||||
|
HISTORY_VISUAL_SET_FUSION_MODES,
|
||||||
HIGHRATE_PERSON_AV_FUSION_MODES,
|
HIGHRATE_PERSON_AV_FUSION_MODES,
|
||||||
LAYER_FUSION_MODES,
|
LAYER_FUSION_MODES,
|
||||||
LOCAL_FUSION_MODES,
|
LOCAL_FUSION_MODES,
|
||||||
@@ -87,6 +89,9 @@ def parse_args() -> argparse.Namespace:
|
|||||||
"highrate-person-av-attention lets global Omni plus one shared "
|
"highrate-person-av-attention lets global Omni plus one shared "
|
||||||
"traditional CPC audio stream query causal 25 Hz person visual/ASD "
|
"traditional CPC audio stream query causal 25 Hz person visual/ASD "
|
||||||
"states sampled at the 5 Hz cutoffs; "
|
"states sampled at the 5 Hz cutoffs; "
|
||||||
|
"fullframe-spatial-attention is F2 over the latest strictly "
|
||||||
|
"pre-cutoff full-frame grid; fullframe-spatiotemporal-attention "
|
||||||
|
"is F3 with a shared causal grid-position GRU; "
|
||||||
"audio-person-frame-set and gated-person-state-adapter are "
|
"audio-person-frame-set and gated-person-state-adapter are "
|
||||||
"current-sidecar F5/F6 screening variants, not full F5/F6."
|
"current-sidecar F5/F6 screening variants, not full F5/F6."
|
||||||
),
|
),
|
||||||
@@ -237,7 +242,7 @@ def _validate_args(args: argparse.Namespace) -> None:
|
|||||||
args.local_feature_root is None or args.local_modalities != "av"
|
args.local_feature_root is None or args.local_modalities != "av"
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Person-set --local-fusion modes require --local-feature-root and "
|
"Set/token --local-fusion modes require --local-feature-root and "
|
||||||
"--local-modalities av"
|
"--local-modalities av"
|
||||||
)
|
)
|
||||||
if args.local_fusion in INITIALIZED_PERSON_RESIDUAL_MODES:
|
if args.local_fusion in INITIALIZED_PERSON_RESIDUAL_MODES:
|
||||||
@@ -528,7 +533,7 @@ def _evaluate_valid_horizons(
|
|||||||
person_mask = None
|
person_mask = None
|
||||||
if people is not None:
|
if people is not None:
|
||||||
rows = torch.arange(start, min(start + batch_size, len(states)))
|
rows = torch.arange(start, min(start + batch_size, len(states)))
|
||||||
if head.local_fusion in TRACK_PERSON_FUSION_MODES:
|
if head.local_fusion in HISTORY_VISUAL_SET_FUSION_MODES:
|
||||||
person_batch, person_mask = people.padded_history(
|
person_batch, person_mask = people.padded_history(
|
||||||
rows,
|
rows,
|
||||||
max_observations=head.person_history_frames,
|
max_observations=head.person_history_frames,
|
||||||
@@ -1004,14 +1009,25 @@ def main() -> None:
|
|||||||
f"projection={args.local_projection_dim or 'identity'}"
|
f"projection={args.local_projection_dim or 'identity'}"
|
||||||
)
|
)
|
||||||
if person_features is not None:
|
if person_features is not None:
|
||||||
print(
|
if args.local_fusion in FULLFRAME_FUSION_MODES:
|
||||||
"frame sets: "
|
print(
|
||||||
f"{len(person_features.frame_offsets) - 1} source frames, "
|
"full-frame token sets: "
|
||||||
f"{len(person_features.features)} person observations, "
|
f"{len(person_features.frame_offsets) - 1} source frames, "
|
||||||
f"max_people={person_features.max_people}, "
|
f"{len(person_features.features)} spatial tokens, "
|
||||||
f"attention={args.person_attention_dim}D/"
|
f"max_tokens_per_frame={person_features.max_people}, "
|
||||||
f"{args.person_attention_heads} heads"
|
f"history_frames={args.person_history_frames}, "
|
||||||
)
|
f"attention={args.person_attention_dim}D/"
|
||||||
|
f"{args.person_attention_heads} heads"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"frame sets: "
|
||||||
|
f"{len(person_features.frame_offsets) - 1} source frames, "
|
||||||
|
f"{len(person_features.features)} person observations, "
|
||||||
|
f"max_people={person_features.max_people}, "
|
||||||
|
f"attention={args.person_attention_dim}D/"
|
||||||
|
f"{args.person_attention_heads} heads"
|
||||||
|
)
|
||||||
trainable_parameters = sum(
|
trainable_parameters = sum(
|
||||||
parameter.numel() for parameter in head.parameters() if parameter.requires_grad
|
parameter.numel() for parameter in head.parameters() if parameter.requires_grad
|
||||||
)
|
)
|
||||||
@@ -1132,7 +1148,7 @@ def main() -> None:
|
|||||||
person_batch = None
|
person_batch = None
|
||||||
person_mask = None
|
person_mask = None
|
||||||
if person_features is not None:
|
if person_features is not None:
|
||||||
if head.local_fusion in TRACK_PERSON_FUSION_MODES:
|
if head.local_fusion in HISTORY_VISUAL_SET_FUSION_MODES:
|
||||||
person_batch, person_mask = person_features.padded_history(
|
person_batch, person_mask = person_features.padded_history(
|
||||||
example_index_batch,
|
example_index_batch,
|
||||||
max_observations=head.person_history_frames,
|
max_observations=head.person_history_frames,
|
||||||
|
|||||||
Reference in New Issue
Block a user