Add overlap working-memory ablation and LoRA epoch2 record
This commit is contained in:
@@ -1104,6 +1104,24 @@ This control should approach the previously reported frozen 8-second score of
|
||||
difference between dense-cache reconstruction and the earlier open-stream
|
||||
feature files; a close match isolates the regression to LoRA/head training.
|
||||
|
||||
### Recorded sparse-LoRA results
|
||||
|
||||
The current single-run EasyCom results confirm negative transfer from this
|
||||
sparse LoRA recipe:
|
||||
|
||||
| Checkpoint | Full-timeline mean mAP | Strict causal mean mAP |
|
||||
|---|---:|---:|
|
||||
| Frozen Thinker dense-cache control | 58.16091 | 58.17146 |
|
||||
| Sparse LoRA epoch1 | 56.52480 | 56.53450 |
|
||||
| Sparse LoRA weights-only epoch2 | 54.19850 | 54.20711 |
|
||||
|
||||
Epoch2 is a weights-only continuation: adapter/head weights resume, but AdamW
|
||||
and the scheduler restart. It is 2.32630 mAP points below epoch1 and 3.96241
|
||||
below the frozen dense-cache control in the full-timeline protocol. The largest
|
||||
regression is camera-wearer speaking. Do not continue the same recipe to a
|
||||
third run. The complete snapshot and per-class metrics are archived in
|
||||
[`results/easycom_omni_lora_sparse_epoch2_2026-08-24/`](results/easycom_omni_lora_sparse_epoch2_2026-08-24/).
|
||||
|
||||
For a speed-only alternative, `train_omni_turn_lora.py` supports a two-GPU
|
||||
producer-consumer benchmark. The producer GPU runs the frozen AV towers and
|
||||
places current-tail embeddings in a bounded CPU queue while the consumer GPU
|
||||
@@ -1233,6 +1251,159 @@ per-class AP over identical seeds. A useful result requires the fused model to
|
||||
beat both controls consistently; otherwise a gain could merely come from the
|
||||
extra fusion parameters or the stronger recent-window shortcut.
|
||||
|
||||
### Frozen serialized-recap ablation (implemented)
|
||||
|
||||
Before adding a learned two-branch fusion module, test a stricter frozen-Omni
|
||||
variant with exactly the same 2048-dimensional readout and prediction head as
|
||||
the current 8-second baseline. Mode `overlap-working-memory` serializes the two
|
||||
timescales inside one still-open Thinker media turn:
|
||||
|
||||
```text
|
||||
total AV presentation budget = 8 s
|
||||
fixed history budget = 6 s = three cached 2 s blocks
|
||||
working recap = 2 s = [t-2,t]
|
||||
|
||||
t = 20.4 s:
|
||||
fixed: [14,16] [16,18] [18,20]
|
||||
working: [18.4,20.4]
|
||||
-> one frozen Thinker state [2048]
|
||||
```
|
||||
|
||||
The overlap is intentional. It presents recent evidence twice but does not add
|
||||
AV-token duration beyond the matched 8-second budget. Thus this ablation trades
|
||||
older unique context for stronger recent evidence instead of silently giving
|
||||
the candidate more input tokens. Fixed blocks retain the global half-open 2 FPS
|
||||
grid. The working recap uses an endpoint-aligned 2 FPS grid and includes the
|
||||
current causal frame:
|
||||
|
||||
```text
|
||||
fixed [18,20): 18.0 18.5 19.0 19.5
|
||||
working [18.4,20.4]: 18.9 19.4 19.9 20.4
|
||||
```
|
||||
|
||||
Keeping four frames means the oldest visual tick is replaced by the current
|
||||
frame rather than adding a fifth frame; this isolates endpoint recency without
|
||||
increasing visual tokens. Audio remains the full, exactly half-open
|
||||
`[start,end)` slice. Consequently, call this an endpoint-aligned recent-video
|
||||
intervention, not an exact timestamp-preserving resampling of both modalities.
|
||||
The recap is serialized as the next TMRoPE chunk; it is deliberately a
|
||||
recent-evidence recap, not a claim that duplicated samples occur later in
|
||||
physical time. This mode is an ablation, not native Qwen streaming semantics,
|
||||
and the Omni backbone stays frozen.
|
||||
|
||||
One deliberate side effect is a 2-second phase cycle: overlap falls toward
|
||||
0.2 seconds just before a fixed boundary and jumps to 2.0 seconds at the
|
||||
boundary. Every output stores this value in
|
||||
`overlap_working_memory_policy.layout`. If aggregate mAP changes, also inspect
|
||||
performance by `step_index % 10`; a boundary-only gain or failure is a chunk
|
||||
phase artifact rather than general evidence for better recent semantics.
|
||||
|
||||
Run a 24-second L20 smoke test first:
|
||||
|
||||
```bash
|
||||
mkdir -p features/easycom_overlap_working_8s_smoke
|
||||
|
||||
CUDA_VISIBLE_DEVICES=3 \
|
||||
OMP_NUM_THREADS=4 \
|
||||
MKL_NUM_THREADS=4 \
|
||||
OPENBLAS_NUM_THREADS=4 \
|
||||
PYTORCH_ALLOC_CONF=expandable_segments:True \
|
||||
python streaming_turn_model_directly.py \
|
||||
--model-id /data1/yfzx/wangx/Models/Qwen2.5-Omni-3B \
|
||||
--easycom-session-dir /data1/yfzx/wangx/Datasets/easycom/extracted_easycom/Main/Video_Compressed/Session_7 \
|
||||
--end 24 \
|
||||
--mode overlap-working-memory \
|
||||
--memory-seconds 8 \
|
||||
--working-memory-seconds 2 \
|
||||
--video-fps 2 \
|
||||
--frame-cache-size 128 \
|
||||
--decoder decord \
|
||||
--decoder-threads 1 \
|
||||
--max-open-readers 1 \
|
||||
--dtype bfloat16 \
|
||||
--flash-attention-2 \
|
||||
--device cuda:0 \
|
||||
--verify-every 10 \
|
||||
--output features/easycom_overlap_working_8s_smoke/Session_7_24s.pt
|
||||
```
|
||||
|
||||
Expected shape is `(120, 2048)`. `overlap_cache_replay` should be numerically
|
||||
exact or effectively exact. Inspect the saved timing policy with:
|
||||
|
||||
```bash
|
||||
python - <<'PY'
|
||||
import torch
|
||||
|
||||
p = torch.load(
|
||||
"features/easycom_overlap_working_8s_smoke/Session_7_24s.pt",
|
||||
map_location="cpu",
|
||||
weights_only=False,
|
||||
)
|
||||
print(p["mode"], tuple(p["states"].shape))
|
||||
print(p["overlap_working_memory_policy"]["working_media"])
|
||||
for row in p["overlap_working_memory_policy"]["layout"][98:102]:
|
||||
print(row)
|
||||
PY
|
||||
```
|
||||
|
||||
For one complete session, replace `--end 24` with its target and use a
|
||||
session-level output filename:
|
||||
|
||||
```bash
|
||||
mkdir -p /data1/yfzx/wangx/Datasets/easycom/omni_features/overlap_working_2fps_8s
|
||||
|
||||
CUDA_VISIBLE_DEVICES=3 \
|
||||
OMP_NUM_THREADS=4 \
|
||||
MKL_NUM_THREADS=4 \
|
||||
OPENBLAS_NUM_THREADS=4 \
|
||||
PYTORCH_ALLOC_CONF=expandable_segments:True \
|
||||
python streaming_turn_model_directly.py \
|
||||
--model-id /data1/yfzx/wangx/Models/Qwen2.5-Omni-3B \
|
||||
--easycom-session-dir /data1/yfzx/wangx/Datasets/easycom/extracted_easycom/Main/Video_Compressed/Session_7 \
|
||||
--target /data1/yfzx/wangx/Datasets/easycom/easycom_target_frame/Session_7.npy \
|
||||
--mode overlap-working-memory \
|
||||
--memory-seconds 8 \
|
||||
--working-memory-seconds 2 \
|
||||
--video-fps 2 \
|
||||
--frame-cache-size 128 \
|
||||
--decoder decord \
|
||||
--decoder-threads 1 \
|
||||
--max-open-readers 1 \
|
||||
--dtype bfloat16 \
|
||||
--flash-attention-2 \
|
||||
--device cuda:0 \
|
||||
--output /data1/yfzx/wangx/Datasets/easycom/omni_features/overlap_working_2fps_8s/Session_7.pt
|
||||
```
|
||||
|
||||
After all train/test sessions have been extracted, train the unchanged frozen
|
||||
head by setting only the expected feature mode:
|
||||
|
||||
```bash
|
||||
python train_turn_head.py \
|
||||
--feature-root /data1/yfzx/wangx/Datasets/easycom/omni_features/overlap_working_2fps_8s \
|
||||
--target-root /data1/yfzx/wangx/Datasets/easycom/easycom_target_frame \
|
||||
--output checkpoints/easycom_overlap_working_frozen_8s.pt \
|
||||
--expected-mode overlap-working-memory \
|
||||
--epochs 50 \
|
||||
--batch-size 512 \
|
||||
--device cuda:0
|
||||
|
||||
python infer_turn_head.py \
|
||||
--feature-root /data1/yfzx/wangx/Datasets/easycom/omni_features/overlap_working_2fps_8s \
|
||||
--target-root /data1/yfzx/wangx/Datasets/easycom/easycom_target_frame \
|
||||
--checkpoint checkpoints/easycom_overlap_working_frozen_8s.pt \
|
||||
--output-dir results/easycom_overlap_working_frozen_8s \
|
||||
--expected-mode overlap-working-memory \
|
||||
--device cuda:0
|
||||
```
|
||||
|
||||
The decisive comparison is against the frozen single-state 8-second baseline
|
||||
using the same split, head, optimization, and seeds. Report the full
|
||||
per-horizon curve: the hypothesis predicts the clearest gain at 0.2--1.0
|
||||
seconds; degradation at 1.2--2.0 seconds would indicate that repeated local
|
||||
cues displaced too much useful long context. Only if this parameter-matched
|
||||
test is positive should the learned two-branch fusion experiment be added.
|
||||
|
||||
## Single-window feature inspection
|
||||
|
||||
`qwen_omni_features.py` is useful for inspecting a short window or layer, but
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# EasyCom: sparse Thinker-LoRA weights-only continuation (epoch2 snapshot)
|
||||
|
||||
Recorded on **2026-08-24** from the remote EasyCom evaluation console. This is
|
||||
a single-run experiment snapshot, not a multi-seed paper result.
|
||||
|
||||
## Experiment contract
|
||||
|
||||
- Dataset: EasyCom with EgoSpeak's official split.
|
||||
- Train sessions: 7, 11, 10, 6, 9, 4, 8, 5, and 12.
|
||||
- Test sessions: 3, 2, and 1 (`n=23048` full-timeline rows).
|
||||
- Backbone: Qwen2.5-Omni-3B Thinker with LoRA plus the `10 x 3` turn head.
|
||||
- Context: 8 seconds; 2 FPS video; predictions and labels at 5 Hz.
|
||||
- Training sampling: transition-sparse anchors (ordinary 1 Hz plus dense 5 Hz
|
||||
anchors near target transitions).
|
||||
- Evaluation: dense 5 Hz AV cache on every test timestamp.
|
||||
- Continuation: adapter and head weights loaded from the epoch1 checkpoint,
|
||||
but optimizer moments and the learning-rate scheduler were reinitialized.
|
||||
Therefore, "epoch2" here means a weights-only second training run, not an
|
||||
exact optimizer-state resume.
|
||||
- Reported checkpoint directory:
|
||||
`checkpoints/omni_turn_lora_easycom_sparse_8s_epoch2_ddp4/`.
|
||||
|
||||
## Summary
|
||||
|
||||
All values are percentages.
|
||||
|
||||
| Checkpoint | Full-timeline mean mAP | Strict causal mean mAP |
|
||||
|---|---:|---:|
|
||||
| Frozen Thinker dense-cache control | 58.16091 | 58.17146 |
|
||||
| Sparse LoRA epoch1 | 56.52480 | 56.53450 |
|
||||
| Sparse LoRA weights-only epoch2 | **54.19850** | **54.20711** |
|
||||
| Epoch2 minus epoch1 | -2.32630 | -2.32739 |
|
||||
| Epoch2 minus frozen control | -3.96241 | -3.96435 |
|
||||
|
||||
The second run is materially worse than both epoch1 and the frozen control.
|
||||
This is not consistent with normal evaluator noise.
|
||||
|
||||
## Per-horizon results
|
||||
|
||||
| Horizon | Full mAP | Strict mAP | Background AP | Wearer AP | Others AP |
|
||||
|---:|---:|---:|---:|---:|---:|
|
||||
| 0.2 s | 65.55214 | 65.55385 | 49.03788 | 65.76312 | 81.85542 |
|
||||
| 0.4 s | 60.96517 | 60.96812 | 43.06922 | 61.14251 | 78.68378 |
|
||||
| 0.6 s | 56.79766 | 56.80301 | 39.37427 | 55.10528 | 75.91344 |
|
||||
| 0.8 s | 54.11741 | 54.12457 | 36.94546 | 51.56720 | 73.83955 |
|
||||
| 1.0 s | 52.09538 | 52.10400 | 35.40706 | 48.38835 | 72.49075 |
|
||||
| 1.2 s | 51.35516 | 51.36504 | 35.30874 | 46.86261 | 71.89412 |
|
||||
| 1.4 s | 50.99774 | 51.00914 | 35.29891 | 45.48818 | 72.20613 |
|
||||
| 1.6 s | 50.47830 | 50.49067 | 34.98109 | 44.74277 | 71.71105 |
|
||||
| 1.8 s | 50.00999 | 50.02296 | 34.64341 | 43.86161 | 71.52496 |
|
||||
| 2.0 s | 49.61605 | 49.62970 | 34.26290 | 43.14791 | 71.43735 |
|
||||
|
||||
The AP columns above are from the full-timeline protocol. Complete AP values
|
||||
for both protocols are stored in [`metrics.csv`](metrics.csv), and
|
||||
[`summary.json`](summary.json) stores the machine-readable aggregate result.
|
||||
|
||||
## Interpretation and decision
|
||||
|
||||
The largest class-specific regression is camera-wearer speaking. Relative to
|
||||
epoch1, wearer AP falls from 68.77118 to 65.76312 at 0.2 seconds and from
|
||||
47.53875 to 43.14791 at 2.0 seconds. Background and other-speaker AP decline
|
||||
less at the long horizon. The pattern indicates negative transfer in the
|
||||
wearer-speaking representation rather than a uniform metric offset.
|
||||
|
||||
Likely contributors are sparse-train/dense-evaluation distribution mismatch,
|
||||
too much LoRA capacity for EasyCom, and restarting the learning-rate schedule
|
||||
during weights-only continuation. Do not run a third continuation epoch with
|
||||
the same recipe. Keep the frozen 8-second result as the main representation
|
||||
baseline and next test the parameter-matched frozen overlap-working-memory
|
||||
ablation.
|
||||
|
||||
## Provenance
|
||||
|
||||
- Source: user-reported `eval_omni_turn_lora.py` console output.
|
||||
- Raw remote `metrics.json` archived here: no; values were transcribed.
|
||||
- Multi-seed: no.
|
||||
- Publication status: diagnostic negative result only.
|
||||
@@ -0,0 +1,21 @@
|
||||
protocol,horizon,seconds,background_ap_percent,camera_wearer_ap_percent,other_people_ap_percent,map_percent,num_steps
|
||||
egospeak_full_timeline,1,0.2,49.03788,65.76312,81.85542,65.55214,23048
|
||||
egospeak_full_timeline,2,0.4,43.06922,61.14251,78.68378,60.96517,23048
|
||||
egospeak_full_timeline,3,0.6,39.37427,55.10528,75.91344,56.79766,23048
|
||||
egospeak_full_timeline,4,0.8,36.94546,51.56720,73.83955,54.11741,23048
|
||||
egospeak_full_timeline,5,1.0,35.40706,48.38835,72.49075,52.09538,23048
|
||||
egospeak_full_timeline,6,1.2,35.30874,46.86261,71.89412,51.35516,23048
|
||||
egospeak_full_timeline,7,1.4,35.29891,45.48818,72.20613,50.99774,23048
|
||||
egospeak_full_timeline,8,1.6,34.98109,44.74277,71.71105,50.47830,23048
|
||||
egospeak_full_timeline,9,1.8,34.64341,43.86161,71.52496,50.00999,23048
|
||||
egospeak_full_timeline,10,2.0,34.26290,43.14791,71.43735,49.61605,23048
|
||||
causal_valid_only,1,0.2,49.03788,65.76312,81.86055,65.55385,23045
|
||||
causal_valid_only,2,0.4,43.06922,61.14251,78.69264,60.96812,23042
|
||||
causal_valid_only,3,0.6,39.37427,55.10988,75.92489,56.80301,23039
|
||||
causal_valid_only,4,0.8,36.94546,51.57526,73.85298,54.12457,23036
|
||||
causal_valid_only,5,1.0,35.40706,48.39889,72.50604,52.10400,23033
|
||||
causal_valid_only,6,1.2,35.30874,46.87568,71.91070,51.36504,23030
|
||||
causal_valid_only,7,1.4,35.29891,45.50340,72.22511,51.00914,23027
|
||||
causal_valid_only,8,1.6,34.98337,44.75739,71.73126,50.49067,23024
|
||||
causal_valid_only,9,1.8,34.64563,43.87551,71.54773,50.02296,23021
|
||||
causal_valid_only,10,2.0,34.26505,43.16123,71.46280,49.62970,23018
|
||||
|
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"recorded_date": "2026-08-24",
|
||||
"status": "single_run_snapshot",
|
||||
"dataset": "EasyCom",
|
||||
"split": {
|
||||
"train_sessions": [7, 11, 10, 6, 9, 4, 8, 5, 12],
|
||||
"test_sessions": [3, 2, 1]
|
||||
},
|
||||
"model": "Qwen2.5-Omni-3B Thinker LoRA",
|
||||
"checkpoint": "checkpoints/omni_turn_lora_easycom_sparse_8s_epoch2_ddp4",
|
||||
"continuation": "adapter_and_head_weights_only_optimizer_and_scheduler_reinitialized",
|
||||
"training_anchor_policy": "transition_sparse_ordinary_1hz_plus_transition_5hz",
|
||||
"evaluation_anchor_policy": "dense_5hz",
|
||||
"prediction_step_seconds": 0.2,
|
||||
"video_fps": 2,
|
||||
"memory_seconds": 8,
|
||||
"metrics_percent": {
|
||||
"epoch2": {
|
||||
"egospeak_full_timeline_mean_map": 54.19850,
|
||||
"causal_valid_only_mean_map": 54.20711,
|
||||
"egospeak_full_timeline_horizon_map": [
|
||||
65.55214,
|
||||
60.96517,
|
||||
56.79766,
|
||||
54.11741,
|
||||
52.09538,
|
||||
51.35516,
|
||||
50.99774,
|
||||
50.47830,
|
||||
50.00999,
|
||||
49.61605
|
||||
],
|
||||
"causal_valid_only_horizon_map": [
|
||||
65.55385,
|
||||
60.96812,
|
||||
56.80301,
|
||||
54.12457,
|
||||
52.10400,
|
||||
51.36504,
|
||||
51.00914,
|
||||
50.49067,
|
||||
50.02296,
|
||||
49.62970
|
||||
]
|
||||
},
|
||||
"epoch1_reference": {
|
||||
"egospeak_full_timeline_mean_map": 56.52480,
|
||||
"causal_valid_only_mean_map": 56.53450
|
||||
},
|
||||
"frozen_dense_cache_control": {
|
||||
"egospeak_full_timeline_mean_map": 58.16091,
|
||||
"causal_valid_only_mean_map": 58.17146
|
||||
},
|
||||
"epoch2_minus_epoch1": {
|
||||
"egospeak_full_timeline_mean_map": -2.32630,
|
||||
"causal_valid_only_mean_map": -2.32739
|
||||
},
|
||||
"epoch2_minus_frozen_dense_cache_control": {
|
||||
"egospeak_full_timeline_mean_map": -3.96241,
|
||||
"causal_valid_only_mean_map": -3.96435
|
||||
}
|
||||
},
|
||||
"decision": "stop_same_recipe_continuation_and_test_frozen_overlap_working_memory",
|
||||
"provenance": {
|
||||
"source": "User-reported remote eval_omni_turn_lora.py console tables",
|
||||
"raw_metrics_json_archived_here": false,
|
||||
"multi_seed": false
|
||||
}
|
||||
}
|
||||
+298
-2
@@ -1,11 +1,13 @@
|
||||
"""Omni-only causal turn-taking features at 5 Hz.
|
||||
|
||||
This module implements two inference paths for Qwen2.5-Omni-3B:
|
||||
This module implements three inference paths for Qwen2.5-Omni-3B:
|
||||
|
||||
1. ``causal-prefix``: recompute a causal AV window ending at every 200 ms.
|
||||
This is deliberately slow, but is the simplest correctness baseline.
|
||||
2. ``open-stream-replay``: cache completed AV blocks inside one
|
||||
still-open user/media turn; closure and readout tokens remain temporary.
|
||||
3. ``overlap-working-memory``: a frozen ablation that serializes cached
|
||||
complete blocks and an overlapping ``[t-2,t]`` recent recap.
|
||||
|
||||
Neither path uses an RNN. A small head maps one Thinker state to the same
|
||||
``10 future horizons x 3 classes`` output used by EgoSpeak.
|
||||
@@ -54,7 +56,11 @@ from transformers import (
|
||||
)
|
||||
from transformers.cache_utils import DynamicCache
|
||||
|
||||
Mode = Literal["causal-prefix", "open-stream-replay"]
|
||||
Mode = Literal[
|
||||
"causal-prefix",
|
||||
"open-stream-replay",
|
||||
"overlap-working-memory",
|
||||
]
|
||||
MODEL_ID = "Qwen/Qwen2.5-Omni-3B"
|
||||
_KNOWN_QWEN_WARNING_TEXT = (
|
||||
"System prompt modified, audio output may not work",
|
||||
@@ -1642,6 +1648,296 @@ class OpenStreamReplayExtractor:
|
||||
)
|
||||
|
||||
|
||||
class OverlappingWorkingMemoryExtractor:
|
||||
"""Serialize cached long memory followed by an overlapping recent recap.
|
||||
|
||||
This is a frozen-representation ablation, not the native Qwen streaming
|
||||
path. At each prediction time, the total AV presentation budget is split
|
||||
between source-aligned complete blocks and one sliding working interval.
|
||||
For an 8-second budget and a 2-second working interval, the serialized
|
||||
Thinker input is three cached 2-second blocks followed by ``[t-2,t]``.
|
||||
The final fixed block and the recap intentionally overlap.
|
||||
|
||||
Fixed and working media readers may implement different visual grids. In
|
||||
the direct-media experiment the fixed blocks remain half-open/global-grid,
|
||||
while the recap contains a frame sampled exactly at current time ``t``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: Qwen2_5OmniThinkerForConditionalGeneration,
|
||||
processor: Qwen2_5OmniProcessor,
|
||||
config: StreamConfig,
|
||||
*,
|
||||
working_memory_seconds: float,
|
||||
fixed_grid_av: FixedGridAV,
|
||||
working_grid_av: FixedGridAV,
|
||||
) -> None:
|
||||
config.validate()
|
||||
if working_memory_seconds <= 0:
|
||||
raise ValueError("working_memory_seconds must be positive")
|
||||
if not math.isclose(
|
||||
working_memory_seconds,
|
||||
config.native_block_seconds,
|
||||
rel_tol=0.0,
|
||||
abs_tol=1e-6,
|
||||
):
|
||||
raise ValueError(
|
||||
"The first overlap-working-memory experiment requires one "
|
||||
"native block of working memory: "
|
||||
f"{working_memory_seconds:g}s vs "
|
||||
f"{config.native_block_seconds:g}s"
|
||||
)
|
||||
total_blocks = _require_integer_ratio(
|
||||
config.memory_seconds,
|
||||
config.native_block_seconds,
|
||||
"total presentation memory / native block",
|
||||
)
|
||||
if total_blocks < 1:
|
||||
raise ValueError("The presentation budget must contain one block")
|
||||
|
||||
self.model = model
|
||||
self.processor = processor
|
||||
self.config = config
|
||||
self.working_memory_seconds = float(working_memory_seconds)
|
||||
# The working recap consumes one block from the matched token budget.
|
||||
self.fixed_block_budget = total_blocks - 1
|
||||
self.fixed_extractor = OpenStreamReplayExtractor(
|
||||
model,
|
||||
processor,
|
||||
config,
|
||||
fixed_grid_av=fixed_grid_av,
|
||||
)
|
||||
self.working_extractor = OpenStreamReplayExtractor(
|
||||
model,
|
||||
processor,
|
||||
config,
|
||||
fixed_grid_av=working_grid_av,
|
||||
)
|
||||
self.query_ids = self.fixed_extractor.query_ids
|
||||
self.layout_records: list[dict[str, Any]] = []
|
||||
|
||||
def _state_from_prepared(
|
||||
self,
|
||||
*,
|
||||
video_path: str | Path | None,
|
||||
audio_path: str | Path | None,
|
||||
fixed_intervals: list[tuple[float, float]],
|
||||
fixed_blocks: list[CachedOpenMediaBlock],
|
||||
working_pieces: OpenMediaPieces,
|
||||
use_audio_in_video: bool,
|
||||
cached_fixed_payload: tuple[Any, int] | None = None,
|
||||
) -> torch.Tensor:
|
||||
positions_per_chunk = _tmrope_positions_per_chunk(self.model)
|
||||
if fixed_intervals:
|
||||
if cached_fixed_payload is None:
|
||||
(
|
||||
fixed_cache,
|
||||
_,
|
||||
payload_base,
|
||||
_,
|
||||
_,
|
||||
) = self.fixed_extractor._cache_from_intervals(
|
||||
video_path=video_path,
|
||||
audio_path=audio_path,
|
||||
intervals=fixed_intervals,
|
||||
use_audio_in_video=use_audio_in_video,
|
||||
prepared_blocks=fixed_blocks,
|
||||
)
|
||||
else:
|
||||
fixed_cache, payload_base = cached_fixed_payload
|
||||
branch_cache = clone_cache(fixed_cache)
|
||||
working_offset = payload_base + len(fixed_intervals) * positions_per_chunk
|
||||
include_prefix = False
|
||||
else:
|
||||
branch_cache = None
|
||||
working_offset = int(working_pieces.payload_position_ids.min().item())
|
||||
include_prefix = True
|
||||
|
||||
(
|
||||
media_cache,
|
||||
_,
|
||||
closure_position_ids,
|
||||
) = _append_open_pieces(
|
||||
self.model,
|
||||
working_pieces,
|
||||
past_key_values=branch_cache,
|
||||
rope_offset=working_offset,
|
||||
include_prefix=include_prefix,
|
||||
)
|
||||
return _read_open_state(
|
||||
self.model,
|
||||
media_cache,
|
||||
closure_ids=working_pieces.closure_ids,
|
||||
closure_position_ids=closure_position_ids,
|
||||
query_ids=self.query_ids,
|
||||
)
|
||||
|
||||
def extract(
|
||||
self,
|
||||
*,
|
||||
video_path: str | Path | None,
|
||||
audio_path: str | Path | None,
|
||||
start: float,
|
||||
end: float,
|
||||
use_audio_in_video: bool,
|
||||
verify_every: int = 0,
|
||||
show_progress: bool = True,
|
||||
) -> ExtractionResult:
|
||||
if verify_every < 0:
|
||||
raise ValueError("verify_every must be >= 0")
|
||||
times = _time_grid(start, end, self.config.step_seconds)
|
||||
block_seconds = self.config.native_block_seconds
|
||||
completed_blocks = CompletedBlockPieceCache(
|
||||
origin=start,
|
||||
block_seconds=block_seconds,
|
||||
max_blocks=max(1, self.fixed_block_budget),
|
||||
)
|
||||
next_fixed_index = 0
|
||||
cached_interval_key: tuple[tuple[float, float], ...] | None = None
|
||||
cached_fixed_payload: tuple[Any, int] | None = None
|
||||
states: list[torch.Tensor] = []
|
||||
checks: list[dict[str, Any]] = []
|
||||
self.layout_records = []
|
||||
|
||||
with tqdm(
|
||||
total=len(times),
|
||||
desc="overlap-working-memory",
|
||||
unit="state",
|
||||
dynamic_ncols=True,
|
||||
mininterval=0.0,
|
||||
miniters=1,
|
||||
disable=not show_progress,
|
||||
) as progress:
|
||||
for step_index, current_time in enumerate(times, start=1):
|
||||
completed_count = int(
|
||||
math.floor((current_time - start) / block_seconds + 1e-7)
|
||||
)
|
||||
if self.fixed_block_budget:
|
||||
while next_fixed_index < completed_count:
|
||||
block_start = start + next_fixed_index * block_seconds
|
||||
block_end = block_start + block_seconds
|
||||
pieces = self.fixed_extractor._prepare_pieces(
|
||||
video_path=video_path,
|
||||
audio_path=audio_path,
|
||||
interval=(block_start, block_end),
|
||||
use_audio_in_video=use_audio_in_video,
|
||||
)
|
||||
completed_blocks.put(
|
||||
CachedOpenMediaBlock(
|
||||
start=block_start,
|
||||
end=block_end,
|
||||
pieces=pieces,
|
||||
)
|
||||
)
|
||||
next_fixed_index += 1
|
||||
|
||||
first_fixed_index = max(
|
||||
0, completed_count - self.fixed_block_budget
|
||||
)
|
||||
fixed_intervals = [
|
||||
(
|
||||
start + index * block_seconds,
|
||||
start + (index + 1) * block_seconds,
|
||||
)
|
||||
for index in range(first_fixed_index, completed_count)
|
||||
]
|
||||
fixed_blocks = (
|
||||
completed_blocks.get_many(fixed_intervals)
|
||||
if fixed_intervals
|
||||
else []
|
||||
)
|
||||
interval_key = tuple(fixed_intervals)
|
||||
if interval_key != cached_interval_key:
|
||||
if fixed_intervals:
|
||||
(
|
||||
fixed_cache,
|
||||
_,
|
||||
payload_base,
|
||||
_,
|
||||
_,
|
||||
) = self.fixed_extractor._cache_from_intervals(
|
||||
video_path=video_path,
|
||||
audio_path=audio_path,
|
||||
intervals=fixed_intervals,
|
||||
use_audio_in_video=use_audio_in_video,
|
||||
prepared_blocks=fixed_blocks,
|
||||
)
|
||||
cached_fixed_payload = (fixed_cache, payload_base)
|
||||
else:
|
||||
cached_fixed_payload = None
|
||||
cached_interval_key = interval_key
|
||||
|
||||
working_start = max(start, current_time - self.working_memory_seconds)
|
||||
working_pieces = self.working_extractor._prepare_pieces(
|
||||
video_path=video_path,
|
||||
audio_path=audio_path,
|
||||
interval=(working_start, current_time),
|
||||
use_audio_in_video=use_audio_in_video,
|
||||
)
|
||||
state = self._state_from_prepared(
|
||||
video_path=video_path,
|
||||
audio_path=audio_path,
|
||||
fixed_intervals=fixed_intervals,
|
||||
fixed_blocks=fixed_blocks,
|
||||
working_pieces=working_pieces,
|
||||
use_audio_in_video=use_audio_in_video,
|
||||
cached_fixed_payload=cached_fixed_payload,
|
||||
)
|
||||
states.append(state.cpu())
|
||||
|
||||
fixed_start = (
|
||||
fixed_intervals[0][0] if fixed_intervals else current_time
|
||||
)
|
||||
fixed_end = fixed_intervals[-1][1] if fixed_intervals else current_time
|
||||
overlap_seconds = (
|
||||
max(0.0, min(fixed_end, current_time) - max(fixed_start, working_start))
|
||||
if fixed_intervals
|
||||
else 0.0
|
||||
)
|
||||
self.layout_records.append(
|
||||
{
|
||||
"time": float(current_time),
|
||||
"fixed_start": float(fixed_start),
|
||||
"fixed_end": float(fixed_end),
|
||||
"fixed_blocks": len(fixed_intervals),
|
||||
"working_start": float(working_start),
|
||||
"working_end": float(current_time),
|
||||
"overlap_seconds": float(overlap_seconds),
|
||||
}
|
||||
)
|
||||
|
||||
if verify_every and step_index % verify_every == 0:
|
||||
exact = self._state_from_prepared(
|
||||
video_path=video_path,
|
||||
audio_path=audio_path,
|
||||
fixed_intervals=fixed_intervals,
|
||||
fixed_blocks=fixed_blocks,
|
||||
working_pieces=working_pieces,
|
||||
use_audio_in_video=use_audio_in_video,
|
||||
)
|
||||
checks.append(
|
||||
_compare_states(
|
||||
current_time,
|
||||
state,
|
||||
exact,
|
||||
kind="overlap_cache_replay",
|
||||
)
|
||||
)
|
||||
|
||||
progress.set_postfix_str(
|
||||
f"media={current_time:.1f}s overlap={overlap_seconds:.1f}s",
|
||||
refresh=False,
|
||||
)
|
||||
progress.update(1)
|
||||
|
||||
return ExtractionResult(
|
||||
times=torch.tensor(times, dtype=torch.float64),
|
||||
states=torch.cat(states, dim=0),
|
||||
verification=checks,
|
||||
)
|
||||
|
||||
|
||||
def _block_intervals(start: float, end: float, block_seconds: float) -> list[tuple[float, float]]:
|
||||
"""Exact AV payload intervals represented by an open-stream cache."""
|
||||
|
||||
|
||||
@@ -496,6 +496,28 @@ class CausalSourceAV:
|
||||
"""Map one global output tick to a source frame at or before it."""
|
||||
|
||||
global_time = grid_index / self.fps
|
||||
clip_index, frame_index, source_time = self._source_frame_for_time(
|
||||
global_time,
|
||||
count_tail_reuse=count_tail_reuse,
|
||||
)
|
||||
return clip_index, frame_index, global_time, source_time
|
||||
|
||||
def _source_frame_for_time(
|
||||
self,
|
||||
global_time: float,
|
||||
*,
|
||||
count_tail_reuse: bool = True,
|
||||
) -> tuple[int, int, float]:
|
||||
"""Map an arbitrary causal timestamp to the latest observed frame."""
|
||||
|
||||
if global_time < -EPSILON:
|
||||
raise ValueError(f"Negative video timestamp: {global_time}")
|
||||
if global_time > self.required_duration + EPSILON:
|
||||
raise ValueError(
|
||||
f"Video timestamp {global_time:g}s exceeds required duration "
|
||||
f"{self.required_duration:g}s"
|
||||
)
|
||||
global_time = min(max(0.0, float(global_time)), self.required_duration)
|
||||
clip_index, clip, local_time = self._clip_for_time(global_time)
|
||||
frame_index = int(
|
||||
np.searchsorted(clip.source_frame_starts, local_time + EPSILON, side="right")
|
||||
@@ -510,7 +532,14 @@ class CausalSourceAV:
|
||||
)
|
||||
if count_tail_reuse and local_time > float(clip.source_frame_starts[-1]) + EPSILON:
|
||||
self._tail_frame_reuses += 1
|
||||
return clip_index, frame_index, global_time, source_time
|
||||
return clip_index, frame_index, source_time
|
||||
|
||||
def frame_at_or_before(self, global_time: float) -> Any:
|
||||
"""Decode the latest source frame whose PTS is not later than ``time``."""
|
||||
|
||||
clip_index, frame_index, _ = self._source_frame_for_time(global_time)
|
||||
self._request_count += 1
|
||||
return self._load_frame(clip_index, frame_index)
|
||||
|
||||
def _load_frame(self, clip_index: int, frame_index: int) -> Any:
|
||||
from PIL import Image
|
||||
@@ -797,6 +826,89 @@ class CausalSourceAV:
|
||||
}
|
||||
|
||||
|
||||
class EndpointAlignedAV:
|
||||
"""Working-window view whose final visual sample is exactly current time.
|
||||
|
||||
Complete cached blocks use the source reader's fixed global half-open grid,
|
||||
for example ``[18.0, 18.5, 19.0, 19.5]`` for ``[18,20)`` at 2 FPS. A
|
||||
two-second working interval ending at ``t`` instead uses
|
||||
``[t-1.5, t-1.0, t-0.5, t]``. The number of visual samples therefore
|
||||
stays matched while the working branch explicitly observes the current
|
||||
causal frame. Audio remains the exact half-open slice ``[start,end)``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: CausalSourceAV,
|
||||
*,
|
||||
max_interval_seconds: float,
|
||||
) -> None:
|
||||
if max_interval_seconds <= 0:
|
||||
raise ValueError("max_interval_seconds must be positive")
|
||||
self.source = source
|
||||
self.fps = source.fps
|
||||
self.sample_rate = source.sample_rate
|
||||
self.max_interval_seconds = float(max_interval_seconds)
|
||||
max_frames = self.max_interval_seconds * self.fps
|
||||
if not math.isclose(max_frames, round(max_frames), abs_tol=1e-6):
|
||||
raise ValueError(
|
||||
"working-memory seconds * video FPS must be integral, got "
|
||||
f"{max_frames}"
|
||||
)
|
||||
self.max_frames = int(round(max_frames))
|
||||
|
||||
def sample_times(self, start: float, end: float) -> list[float]:
|
||||
if end <= start:
|
||||
raise ValueError(f"Empty video interval [{start}, {end}]")
|
||||
duration = end - start
|
||||
if duration > self.max_interval_seconds + EPSILON:
|
||||
raise ValueError(
|
||||
f"Working interval is {duration:g}s, exceeds configured "
|
||||
f"{self.max_interval_seconds:g}s"
|
||||
)
|
||||
count = max(1, int(math.ceil(duration * self.fps - EPSILON)))
|
||||
count = min(count, self.max_frames)
|
||||
first = end - (count - 1) / self.fps
|
||||
if first < start - EPSILON:
|
||||
raise RuntimeError(
|
||||
f"Endpoint-aligned grid starts before its interval: "
|
||||
f"{first:g} < {start:g}"
|
||||
)
|
||||
return [first + index / self.fps for index in range(count)]
|
||||
|
||||
def frames(self, start: float, end: float) -> list[Any]:
|
||||
frames = [
|
||||
self.source.frame_at_or_before(timestamp)
|
||||
for timestamp in self.sample_times(start, end)
|
||||
]
|
||||
if len(frames) % 2:
|
||||
frames.append(frames[-1].copy())
|
||||
self.source._odd_frame_duplications += 1
|
||||
return frames
|
||||
|
||||
def audio_slice(self, start: float, end: float) -> np.ndarray:
|
||||
return self.source.audio_slice(start, end)
|
||||
|
||||
def metadata(self) -> dict[str, Any]:
|
||||
return {
|
||||
"visual_sampling": "endpoint_aligned_latest_source_pts_not_after_tick",
|
||||
"audio_interval": "half_open_[start,end)",
|
||||
"working_memory_seconds": self.max_interval_seconds,
|
||||
"output_video_fps": self.fps,
|
||||
"full_window_frame_count": self.max_frames,
|
||||
"full_window_relative_video_times": [
|
||||
-(self.max_frames - 1 - index) / self.fps
|
||||
for index in range(self.max_frames)
|
||||
],
|
||||
"includes_current_video_frame": True,
|
||||
"oldest_visual_tick_replaced_by_current": True,
|
||||
"timestamp_note": (
|
||||
"video is endpoint-aligned while audio retains the full "
|
||||
"half-open working interval"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _manifest_session_specs(
|
||||
manifest_path: Path,
|
||||
session_name: str,
|
||||
@@ -901,7 +1013,11 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser.add_argument("--output", type=Path)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=("causal-prefix", "open-stream-replay"),
|
||||
choices=(
|
||||
"causal-prefix",
|
||||
"open-stream-replay",
|
||||
"overlap-working-memory",
|
||||
),
|
||||
default="open-stream-replay",
|
||||
)
|
||||
parser.add_argument("--model-id", default=core.MODEL_ID)
|
||||
@@ -909,6 +1025,15 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser.add_argument("--end", type=float)
|
||||
parser.add_argument("--step-seconds", type=float, default=0.2)
|
||||
parser.add_argument("--native-block-seconds", type=float, default=2.0)
|
||||
parser.add_argument(
|
||||
"--working-memory-seconds",
|
||||
type=float,
|
||||
default=2.0,
|
||||
help=(
|
||||
"Sliding recent recap used by overlap-working-memory; its duration "
|
||||
"is included in --memory-seconds rather than added on top"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--memory-seconds",
|
||||
type=float,
|
||||
@@ -1046,6 +1171,16 @@ def main(
|
||||
raise ValueError("model and processor must be supplied together")
|
||||
if args.verify_every < 0 or args.verify_continuous_every < 0:
|
||||
raise SystemExit("verification intervals must be >= 0")
|
||||
if args.working_memory_seconds <= 0:
|
||||
raise SystemExit("--working-memory-seconds must be positive")
|
||||
if (
|
||||
args.mode == "overlap-working-memory"
|
||||
and args.verify_continuous_every > 0
|
||||
):
|
||||
raise SystemExit(
|
||||
"--verify-continuous-every is not defined for the intentional "
|
||||
"overlap representation; use --verify-every"
|
||||
)
|
||||
if args.frame_cache_size < 0:
|
||||
raise SystemExit("--frame-cache-size must be >= 0")
|
||||
if args.output is None and not args.media_only:
|
||||
@@ -1131,6 +1266,11 @@ def main(
|
||||
return
|
||||
|
||||
duration = end - args.start
|
||||
if args.mode == "overlap-working-memory" and args.memory_seconds <= 0:
|
||||
raise SystemExit(
|
||||
"overlap-working-memory uses a matched finite presentation budget; "
|
||||
"pass a positive --memory-seconds (recommended: 8)"
|
||||
)
|
||||
effective_memory = _effective_memory_seconds(
|
||||
args.memory_seconds,
|
||||
duration=duration,
|
||||
@@ -1167,6 +1307,8 @@ def main(
|
||||
model.requires_grad_(False)
|
||||
media_path_for_api = reader.clips[0].path
|
||||
|
||||
overlap_extractor = None
|
||||
working_grid_av = None
|
||||
if args.mode == "causal-prefix":
|
||||
extractor = core.CausalPrefixExtractor(
|
||||
model, processor, config, fixed_grid_av=reader
|
||||
@@ -1179,7 +1321,7 @@ def main(
|
||||
use_audio_in_video=True,
|
||||
show_progress=not args.no_progress,
|
||||
)
|
||||
else:
|
||||
elif args.mode == "open-stream-replay":
|
||||
extractor = core.OpenStreamReplayExtractor(
|
||||
model, processor, config, fixed_grid_av=reader
|
||||
)
|
||||
@@ -1193,20 +1335,51 @@ def main(
|
||||
verify_continuous_every=args.verify_continuous_every,
|
||||
show_progress=not args.no_progress,
|
||||
)
|
||||
else:
|
||||
working_grid_av = EndpointAlignedAV(
|
||||
reader,
|
||||
max_interval_seconds=args.working_memory_seconds,
|
||||
)
|
||||
overlap_extractor = core.OverlappingWorkingMemoryExtractor(
|
||||
model,
|
||||
processor,
|
||||
config,
|
||||
working_memory_seconds=args.working_memory_seconds,
|
||||
fixed_grid_av=reader,
|
||||
working_grid_av=working_grid_av,
|
||||
)
|
||||
result = overlap_extractor.extract(
|
||||
video_path=media_path_for_api,
|
||||
audio_path=None,
|
||||
start=args.start,
|
||||
end=end,
|
||||
use_audio_in_video=True,
|
||||
verify_every=args.verify_every,
|
||||
show_progress=not args.no_progress,
|
||||
)
|
||||
|
||||
if target_steps is not None and len(result.times) != target_steps:
|
||||
raise RuntimeError(
|
||||
f"Extractor returned {len(result.times)} states, expected {target_steps}"
|
||||
)
|
||||
|
||||
persistent_memory = args.memory_seconds == 0
|
||||
persistent_memory = (
|
||||
args.mode == "open-stream-replay" and args.memory_seconds == 0
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"model_id": args.model_id,
|
||||
"mode": args.mode,
|
||||
"stream_representation": (
|
||||
"single_causal_prefix"
|
||||
if args.mode == "causal-prefix"
|
||||
else "single_open_media_turn_with_2s_payload_commits"
|
||||
else (
|
||||
"single_open_media_turn_with_2s_payload_commits"
|
||||
if args.mode == "open-stream-replay"
|
||||
else (
|
||||
"serialized_cached_complete_blocks_then_overlapping_"
|
||||
"endpoint_aligned_recent_recap"
|
||||
)
|
||||
)
|
||||
),
|
||||
"config": asdict(config),
|
||||
"requested_memory_seconds": args.memory_seconds,
|
||||
@@ -1221,7 +1394,9 @@ def main(
|
||||
"source_manifest_record": manifest_record,
|
||||
"direct_media": reader.metadata(),
|
||||
"video_sampling_grid": (
|
||||
"direct_pts_causal_global_grid_half_open_with_tail_duplication"
|
||||
"fixed_global_half_open_plus_endpoint_aligned_working_recap"
|
||||
if args.mode == "overlap-working-memory"
|
||||
else "direct_pts_causal_global_grid_half_open_with_tail_duplication"
|
||||
),
|
||||
"open_stream_policy": (
|
||||
{
|
||||
@@ -1242,6 +1417,39 @@ def main(
|
||||
if args.mode == "open-stream-replay"
|
||||
else None
|
||||
),
|
||||
"overlap_working_memory_policy": (
|
||||
{
|
||||
"total_presentation_seconds": config.memory_seconds,
|
||||
"fixed_history_seconds": (
|
||||
config.memory_seconds - args.working_memory_seconds
|
||||
),
|
||||
"fixed_block_seconds": config.native_block_seconds,
|
||||
"working_memory_seconds": args.working_memory_seconds,
|
||||
"fixed_blocks": (
|
||||
overlap_extractor.fixed_block_budget
|
||||
if overlap_extractor is not None
|
||||
else None
|
||||
),
|
||||
"serialization": (
|
||||
"fixed complete AV payloads followed by overlapping "
|
||||
"working AV payload in the same open media turn"
|
||||
),
|
||||
"overlap_is_intentional": True,
|
||||
"working_media": (
|
||||
working_grid_av.metadata()
|
||||
if working_grid_av is not None
|
||||
else None
|
||||
),
|
||||
"layout": (
|
||||
overlap_extractor.layout_records
|
||||
if overlap_extractor is not None
|
||||
else None
|
||||
),
|
||||
"backbone": "frozen",
|
||||
}
|
||||
if args.mode == "overlap-working-memory"
|
||||
else None
|
||||
),
|
||||
"times": result.times,
|
||||
"states": result.states,
|
||||
"verification": result.verification,
|
||||
|
||||
Reference in New Issue
Block a user