cyanwingsbird commited on
Commit
303c26c
·
verified ·
1 Parent(s): 79130aa

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -47,25 +47,33 @@ Run every command **from the repo root** — modules use repo-root-relative `dat
47
  ```bash
48
  python -m venv .venv && .venv\Scripts\activate # Windows (use: source .venv/bin/activate on *nix)
49
  pip install -e . # installs humomni.* + deps (pyproject.toml)
50
- cp .env.example .env # (cmd.exe: copy) then fill in the keys (below)
51
  ```
52
  `.env` needs `OPENROUTER_API_KEY` (the VLM) and `VERTEX_KEY` (the Gemini judge used for the LLM-entailment
53
- dedup) — only for regenerating decisions from frames; the offline replay needs no keys. Put the test set
54
- (the official Track-2 phase-1 release) at `data/phase1/data/<id>/` — frames `0.5.jpg, 1.0.jpg, …` plus
55
- `question.json` per sample. (This is the `data/<id>/` layout the report appendix refers to, rooted at
56
- `data/phase1/data/`; a different location needs `--data_dir`, and a wrong path now aborts loudly.)
57
 
58
  ## Build the submission — one command
59
  `config.json` holds the entire method (`"ensemble": ["v5","v3","v8"] @ 40`, `dedup`/`theta`, and the
60
  perception `"gate"` disabled by default — `use_gate:false` gives the shipped max-score point; enabling it
61
  at novelty ≥ 16 / silence 3 s selects a lower-cost operating point):
62
  ```bash
63
- python scripts/build_submission.py --out submissions/tryanderror2.jsonl
 
 
 
 
64
  ```
65
- For each ensemble prompt it ensures a per-prompt VLM decision cache **building it from the test frames
66
- if missing** (paid: ~1 VLM call per prompt per frame; resumable, skips cached frames) — then replays the
67
- merge + LLM-entailment dedup and **validates** the output (all 500 ids present, times multiples of 0.5).
68
- Re-runs are free once the caches exist.
 
 
 
 
69
 
70
  The live equivalent produces byte-identical output (paid on every run — use when caches are absent):
71
  ```bash
@@ -80,3 +88,13 @@ Both drive the same `APIPolicy` from `config.json`, so all three commands point
80
  python tests/test_causality.py # causality assert fires on shuffled / out-of-order frames
81
  python tests/test_inference_parity.py # run_inference == infer_simple; ensemble multi-emit per frame
82
  ```
 
 
 
 
 
 
 
 
 
 
 
47
  ```bash
48
  python -m venv .venv && .venv\Scripts\activate # Windows (use: source .venv/bin/activate on *nix)
49
  pip install -e . # installs humomni.* + deps (pyproject.toml)
50
+ cp .env.example .env # then fill in the keys (below)
51
  ```
52
  `.env` needs `OPENROUTER_API_KEY` (the VLM) and `VERTEX_KEY` (the Gemini judge used for the LLM-entailment
53
+ dedup) — only for regenerating decisions from frames; the offline replay needs no keys. Put the test set at
54
+ `data/phase1/data/<id>/` — frames `0.5.jpg, 1.0.jpg, …` (`.png` also supported, as shipped with the
55
+ phase-2 test set) plus `question.json` per sample; `--data_dir` selects the test set (per-prompt caches
56
+ are kept separate per phase automatically).
57
 
58
  ## Build the submission — one command
59
  `config.json` holds the entire method (`"ensemble": ["v5","v3","v8"] @ 40`, `dedup`/`theta`, and the
60
  perception `"gate"` disabled by default — `use_gate:false` gives the shipped max-score point; enabling it
61
  at novelty ≥ 16 / silence 3 s selects a lower-cost operating point):
62
  ```bash
63
+ # phase-1 test set
64
+ python scripts/build_submission.py --data_dir data/phase1/data --out submissions/tryanderror2.jsonl
65
+ # phase-2 test set — same pipeline; its predictions use the EXH+COMP @ k32 operating point
66
+ # (config_phase2.json), selected for cost; see Table I of the report
67
+ python scripts/build_submission.py --data_dir data/phase2/data --config config_phase2.json --out submissions/tryanderror2_phase2.jsonl
68
  ```
69
+ `--data_dir` points at any folder of `<id>/` sample dirs (frames `0.5.jpg`/`0.5.png`, `1.0.…`, plus
70
+ `question.json`; both extensions are auto-detected). For each ensemble prompt it ensures a per-prompt VLM
71
+ decision cache **building it from the test frames if missing** (paid: ~1 VLM call per prompt per frame;
72
+ resumable, skips cached frames) — then replays the merge + LLM-entailment dedup and **validates** the
73
+ output (all ids present, times multiples of 0.5). Re-runs are free once the caches exist. Runs on
74
+ different test sets never mix: decision/novelty caches are namespaced per data folder
75
+ (`cache/phase1_*.jsonl` vs `cache/phase2_*.jsonl`), and the resume file is fingerprinted by
76
+ (data path + config) so a leftover from a different run is set aside instead of resumed.
77
 
78
  The live equivalent produces byte-identical output (paid on every run — use when caches are absent):
79
  ```bash
 
88
  python tests/test_causality.py # causality assert fires on shuffled / out-of-order frames
89
  python tests/test_inference_parity.py # run_inference == infer_simple; ensemble multi-emit per frame
90
  ```
91
+
92
+ ## Compliance
93
+ Track 2 permits any open-source or commercial LLM API (organizer clarification, 2026-07-07), with one
94
+ condition: **when generating a response at time t, only frames at time ≤ t may be used as visual input.**
95
+ This system enforces that structurally (below) and tests it.
96
+
97
+ ## The one hard rule — causality
98
+ Acting at time *t* uses **only** frames with timestamp ≤ *t*. Enforced structurally in
99
+ `humomni.core.streaming_driver.drive` (`assert t > last_t`) and guarded by `tests/test_causality.py`
100
+ (the shuffled-frame test). Keep it green always.
config_phase2.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "theta": 0.6,
3
+ "gate": {
4
+ "use_gate": false,
5
+ "novelty": 16,
6
+ "max_silence_s": 3.0,
7
+ "warmup_s": 1.0
8
+ },
9
+ "dedup": {
10
+ "sim_high": 0.97,
11
+ "sim_low": 0.55,
12
+ "recent_k": 16,
13
+ "use_llm": true
14
+ },
15
+ "ensemble": {
16
+ "prompts": [
17
+ "v5",
18
+ "v3"
19
+ ],
20
+ "recent_k": 32
21
+ }
22
+ }
paper_experiments/README.md CHANGED
@@ -7,7 +7,7 @@ calls** and needs **no keys**.
7
  ## Setup (once)
8
 
9
  ```bash
10
- pip install -e . # from the repo root; the run itself is fully offline (embeddings ship as a cache)
11
  ```
12
 
13
  ## Run
@@ -32,7 +32,6 @@ Runs in a few minutes on CPU.
32
  | `data/gold_holdout.json` | the 100 untouched holdout items (public WEB split, items 400–499) |
33
  | `caches/web_{base,comp,exh,fs,fs_alt}.jsonl` | per-frame VLM decisions for each prompt (temperature 0), holdout videos only |
34
  | `caches/judge_cache.json` | every LLM-judge / entailment result, keyed by input hash |
35
- | `caches/emb_cache.{npy,keys.json}` | MiniLM embeddings of every cached draft (the cosine-dedup prefilter), so the replay never loads — or downloads — the embedder |
36
 
37
  `run.py` replays the cached decisions through the *same* merge + entailment-dedup code the live system
38
  runs (`humomni.phase1.dedup.EntailmentGuard`), then scores with the official-protocol harness
 
7
  ## Setup (once)
8
 
9
  ```bash
10
+ pip install -e . # from the repo root; first run downloads the MiniLM embedder (~90 MB)
11
  ```
12
 
13
  ## Run
 
32
  | `data/gold_holdout.json` | the 100 untouched holdout items (public WEB split, items 400–499) |
33
  | `caches/web_{base,comp,exh,fs,fs_alt}.jsonl` | per-frame VLM decisions for each prompt (temperature 0), holdout videos only |
34
  | `caches/judge_cache.json` | every LLM-judge / entailment result, keyed by input hash |
 
35
 
36
  `run.py` replays the cached decisions through the *same* merge + entailment-dedup code the live system
37
  runs (`humomni.phase1.dedup.EntailmentGuard`), then scores with the official-protocol harness
paper_experiments/run.py CHANGED
@@ -13,8 +13,7 @@
13
  # with the official-protocol harness using the shipped LLM-judge cache (caches/judge_cache.json) —
14
  # so a rerun makes ZERO API calls and reproduces the reported numbers exactly. The perception gate is
15
  # replayed from the shipped per-frame novelty cache (caches/novelty_holdout.json). Requires only
16
- # `pip install -e .`; the draft embeddings ship too (caches/emb_cache.*), so no model download —
17
- # the run is fully offline.
18
  import argparse
19
  import json
20
  import os
@@ -50,9 +49,6 @@ for c in CACHES.values():
50
  _TIMES = {q: sorted(ts) for q, ts in _TIMES.items()}
51
 
52
  emb_cache.load_disk()
53
- emb_cache.load_disk(os.path.join(HERE, "caches", "emb_cache")) # shipped draft embeddings —
54
- # covers every draft below, so the MiniLM model (a ~90 MB first-run download) is never needed
55
- # for the replay; embed_many only encodes cache MISSES.
56
  emb_cache.embed_many([d["draft"].strip() for c in CACHES.values()
57
  for d in c.mem.values() if d.get("draft", "").strip()])
58
 
 
13
  # with the official-protocol harness using the shipped LLM-judge cache (caches/judge_cache.json) —
14
  # so a rerun makes ZERO API calls and reproduces the reported numbers exactly. The perception gate is
15
  # replayed from the shipped per-frame novelty cache (caches/novelty_holdout.json). Requires only
16
+ # `pip install -e .` (first run downloads the MiniLM sentence embedder, ~90 MB).
 
17
  import argparse
18
  import json
19
  import os
 
49
  _TIMES = {q: sorted(ts) for q, ts in _TIMES.items()}
50
 
51
  emb_cache.load_disk()
 
 
 
52
  emb_cache.embed_many([d["draft"].strip() for c in CACHES.values()
53
  for d in c.mem.values() if d.get("draft", "").strip()])
54
 
pyproject.toml CHANGED
@@ -7,10 +7,8 @@ name = "humomni-track2"
7
  version = "0.1.0"
8
  description = "HumOmni 2026 Track 2 (ProactivEval) — causal proactive streaming video QA (Phase-1 API orchestrator)"
9
  requires-python = ">=3.10"
10
- # Pinned to the versions the reported results were produced with (report Appendix, "Libraries").
11
  dependencies = [
12
- "openai==2.43.0", "pillow==12.2.0", "numpy==2.2.6",
13
- "scikit-learn==1.7.2", "sentence-transformers==5.6.0", "tqdm==4.68.3",
14
  ]
15
  # Note: the Gemini judge talks to Vertex AI express mode over plain REST (stdlib urllib) — no SDK dep.
16
 
 
7
  version = "0.1.0"
8
  description = "HumOmni 2026 Track 2 (ProactivEval) — causal proactive streaming video QA (Phase-1 API orchestrator)"
9
  requires-python = ">=3.10"
 
10
  dependencies = [
11
+ "openai", "pillow", "numpy", "scikit-learn", "sentence-transformers", "tqdm",
 
12
  ]
13
  # Note: the Gemini judge talks to Vertex AI express mode over plain REST (stdlib urllib) — no SDK dep.
14
 
scripts/build_submission.py CHANGED
@@ -42,13 +42,15 @@ def _checkpoint(every=20):
42
  judges.jc_save()
43
 
44
 
 
 
 
 
 
 
 
 
45
  def build(data_dir, out, cfg, cache_tpl, workers, limit=0, build_workers=8):
46
- # Fail LOUDLY on a wrong/empty data_dir: an empty gold set would otherwise sail through
47
- # validation ("wrote 0 samples ... VALID") and produce an uploadable-looking empty file.
48
- if not any(os.path.isdir(x) for x in glob.glob(os.path.join(data_dir, "*"))):
49
- sys.exit(f"no sample dirs found under {data_dir!r} — expected the test set at "
50
- f"data/phase1/data/<id>/ (frames 0.5.jpg, 1.0.jpg, ... + question.json), "
51
- f"or pass --data_dir")
52
  ens = cfg.get("ensemble")
53
  if not ens:
54
  sys.exit("config.json has no 'ensemble' block — the best method is the v5+v3 ensemble; add it.")
@@ -64,7 +66,7 @@ def build(data_dir, out, cfg, cache_tpl, workers, limit=0, build_workers=8):
64
  novelty = None
65
  if g.get("use_gate"):
66
  from humomni.tuning.cache import build_novelty
67
- nov_path = "cache/phase1_novelty.json"
68
  if not os.path.exists(nov_path):
69
  print(f"[gate] computing per-frame novelty for {data_dir} -> {nov_path} (CPU, one-off) …", flush=True)
70
  build_novelty(data_dir=data_dir, out=nov_path) # ALWAYS full — a --limit-partial cache would poison later runs
@@ -96,17 +98,38 @@ def build(data_dir, out, cfg, cache_tpl, workers, limit=0, build_workers=8):
96
  # Sample-level RESUME: completed records are appended to a `<out>.partial` sidecar as they finish,
97
  # so a killed build resumes instead of restarting (the dedup judge cache is already persisted
98
  # incrementally). The final `out` is written — and the sidecar dropped — only once all ids are done.
 
 
 
 
 
 
99
  partial = out + ".partial"
100
  done = {}
101
  if os.path.exists(partial):
102
- for line in open(partial, encoding="utf-8"):
 
 
103
  try:
104
- done[json.loads(line)["question_id"]] = line.rstrip("\n")
105
  except Exception:
106
- pass
 
 
 
 
 
 
 
 
 
 
107
  if done:
108
  print(f"[resume] {len(done)} samples already done in {partial}", flush=True)
109
  pw = open(partial, "a", encoding="utf-8")
 
 
 
110
  pwlock = threading.Lock()
111
 
112
  def one(sd):
@@ -159,13 +182,17 @@ if __name__ == "__main__":
159
  ap.add_argument("--data_dir", default="data/phase1/data")
160
  ap.add_argument("--out", default="submissions/submission.jsonl")
161
  ap.add_argument("--config", default="config.json")
162
- ap.add_argument("--cache_tpl", default="cache/phase1_{}.jsonl", help="per-prompt cache path ({} = prompt name)")
 
 
163
  ap.add_argument("--workers", type=int, default=1,
164
  help="parallel samples. Keep at 1 on a COLD judge cache (the LLM dedup floods Vertex → 429); "
165
  "raise to 3-4 once cached (re-run is then free).")
166
  ap.add_argument("--limit", type=int, default=0, help="only the first N samples (quick correctness check)")
167
  ap.add_argument("--build_workers", type=int, default=8, help="parallelism for BUILDING missing per-prompt VLM caches")
168
  a = ap.parse_args()
 
 
169
  cfg = json.load(open(a.config, encoding="utf-8"))
170
  os.makedirs(os.path.dirname(a.out) or ".", exist_ok=True)
171
 
 
42
  judges.jc_save()
43
 
44
 
45
+ def _phase_of(data_dir):
46
+ """Phase tag derived from the data path: data/phase1/data -> 'phase1', data/phase2/data -> 'phase2'.
47
+ Keeps the per-prompt decision caches and the gate-novelty cache of different test sets separate,
48
+ so the SAME command works on any phase by changing only --data_dir."""
49
+ parent = os.path.basename(os.path.dirname(os.path.normpath(data_dir)))
50
+ return parent or os.path.basename(os.path.normpath(data_dir)) or "phase1"
51
+
52
+
53
  def build(data_dir, out, cfg, cache_tpl, workers, limit=0, build_workers=8):
 
 
 
 
 
 
54
  ens = cfg.get("ensemble")
55
  if not ens:
56
  sys.exit("config.json has no 'ensemble' block — the best method is the v5+v3 ensemble; add it.")
 
66
  novelty = None
67
  if g.get("use_gate"):
68
  from humomni.tuning.cache import build_novelty
69
+ nov_path = f"cache/{_phase_of(data_dir)}_novelty.json"
70
  if not os.path.exists(nov_path):
71
  print(f"[gate] computing per-frame novelty for {data_dir} -> {nov_path} (CPU, one-off) …", flush=True)
72
  build_novelty(data_dir=data_dir, out=nov_path) # ALWAYS full — a --limit-partial cache would poison later runs
 
98
  # Sample-level RESUME: completed records are appended to a `<out>.partial` sidecar as they finish,
99
  # so a killed build resumes instead of restarting (the dedup judge cache is already persisted
100
  # incrementally). The final `out` is written — and the sidecar dropped — only once all ids are done.
101
+ # The sidecar's first line is a FINGERPRINT of (data_dir + method config): a leftover partial from a
102
+ # different test set or a different config is set aside instead of resumed, so two runs can never mix
103
+ # samples computed under different settings into one output.
104
+ import hashlib
105
+ fp = hashlib.md5((os.path.normpath(data_dir) + "\x00"
106
+ + json.dumps(cfg, sort_keys=True, ensure_ascii=False)).encode("utf-8")).hexdigest()
107
  partial = out + ".partial"
108
  done = {}
109
  if os.path.exists(partial):
110
+ lines = open(partial, encoding="utf-8").read().splitlines()
111
+ meta_ok = False
112
+ if lines:
113
  try:
114
+ meta_ok = json.loads(lines[0]).get("__partial_config__") == fp
115
  except Exception:
116
+ meta_ok = False
117
+ if meta_ok:
118
+ for line in lines[1:]:
119
+ try:
120
+ done[json.loads(line)["question_id"]] = line
121
+ except Exception:
122
+ pass
123
+ else:
124
+ os.replace(partial, partial + ".stale")
125
+ print(f"[resume] {partial} was built under a DIFFERENT data set or config — "
126
+ f"set aside as .stale, starting fresh", flush=True)
127
  if done:
128
  print(f"[resume] {len(done)} samples already done in {partial}", flush=True)
129
  pw = open(partial, "a", encoding="utf-8")
130
+ if not done and os.path.getsize(partial) == 0: # fresh sidecar -> stamp the fingerprint header
131
+ pw.write(json.dumps({"__partial_config__": fp}) + "\n")
132
+ pw.flush()
133
  pwlock = threading.Lock()
134
 
135
  def one(sd):
 
182
  ap.add_argument("--data_dir", default="data/phase1/data")
183
  ap.add_argument("--out", default="submissions/submission.jsonl")
184
  ap.add_argument("--config", default="config.json")
185
+ ap.add_argument("--cache_tpl", default=None,
186
+ help="per-prompt cache path ({} = prompt name); default derives from --data_dir, "
187
+ "e.g. data/phase2/data -> cache/phase2_{}.jsonl")
188
  ap.add_argument("--workers", type=int, default=1,
189
  help="parallel samples. Keep at 1 on a COLD judge cache (the LLM dedup floods Vertex → 429); "
190
  "raise to 3-4 once cached (re-run is then free).")
191
  ap.add_argument("--limit", type=int, default=0, help="only the first N samples (quick correctness check)")
192
  ap.add_argument("--build_workers", type=int, default=8, help="parallelism for BUILDING missing per-prompt VLM caches")
193
  a = ap.parse_args()
194
+ if not a.cache_tpl:
195
+ a.cache_tpl = f"cache/{_phase_of(a.data_dir)}_{{}}.jsonl"
196
  cfg = json.load(open(a.config, encoding="utf-8"))
197
  os.makedirs(os.path.dirname(a.out) or ".", exist_ok=True)
198
 
scripts/infer_simple.py CHANGED
@@ -25,13 +25,14 @@ def make_policy(name):
25
 
26
  def _video_length(sample_dir):
27
  """The latest frame timestamp present — the 'video_length' the loop steps up to (frames sit on a
28
- 0.5 s grid: 0.5.jpg, 1.0.jpg, …)."""
29
  ts = []
30
- for p in glob.glob(os.path.join(sample_dir, "*.jpg")):
31
- try:
32
- ts.append(float(os.path.basename(p)[:-4]))
33
- except ValueError:
34
- pass # skip non-frame files
 
35
  return max(ts) if ts else 0.0
36
 
37
 
@@ -60,6 +61,8 @@ def infer_one(sample_dir, policy):
60
  for step in range(1, int(round(video_length / 0.5)) + 1): # current_time = 0.5 .. video_length
61
  current_time = step * 0.5
62
  frame_path = os.path.join(sample_dir, f"{current_time:.1f}.jpg")
 
 
63
  if not os.path.exists(frame_path): # missing tick on the grid — skip
64
  continue
65
  assert current_time > last_t, "frames must arrive in order (no future frames)"
 
25
 
26
  def _video_length(sample_dir):
27
  """The latest frame timestamp present — the 'video_length' the loop steps up to (frames sit on a
28
+ 0.5 s grid: 0.5.jpg / 0.5.png, 1.0.jpg / 1.0.png, …)."""
29
  ts = []
30
+ for pat in ("*.jpg", "*.png"): # phase-1 ships .jpg, phase-2 .png
31
+ for p in glob.glob(os.path.join(sample_dir, pat)):
32
+ try:
33
+ ts.append(float(os.path.basename(p)[:-4]))
34
+ except ValueError:
35
+ pass # skip non-frame files
36
  return max(ts) if ts else 0.0
37
 
38
 
 
61
  for step in range(1, int(round(video_length / 0.5)) + 1): # current_time = 0.5 .. video_length
62
  current_time = step * 0.5
63
  frame_path = os.path.join(sample_dir, f"{current_time:.1f}.jpg")
64
+ if not os.path.exists(frame_path): # phase-2 frames are .png
65
+ frame_path = os.path.join(sample_dir, f"{current_time:.1f}.png")
66
  if not os.path.exists(frame_path): # missing tick on the grid — skip
67
  continue
68
  assert current_time > last_t, "frames must arrive in order (no future frames)"
src/humomni/core/streaming_driver.py CHANGED
@@ -16,12 +16,13 @@ def iter_frames(sample_dir):
16
  Non-frame files (e.g. question.json) and any unparsable stems are skipped.
17
  """
18
  items = []
19
- for f in glob.glob(os.path.join(sample_dir, "*.jpg")):
20
- stem = os.path.basename(f)[:-4]
21
- try:
22
- items.append((float(stem), f))
23
- except ValueError:
24
- pass # skip non-frame files
 
25
  items.sort(key=lambda x: x[0])
26
  out, last = [], None
27
  for t, f in items: # drop a malformed duplicate-timestamp frame (would trip drive()'s assert)
 
16
  Non-frame files (e.g. question.json) and any unparsable stems are skipped.
17
  """
18
  items = []
19
+ for pat in ("*.jpg", "*.png"): # phase-1 test data ships .jpg frames, phase-2 ships .png
20
+ for f in glob.glob(os.path.join(sample_dir, pat)):
21
+ stem = os.path.basename(f)[:-4]
22
+ try:
23
+ items.append((float(stem), f))
24
+ except ValueError:
25
+ pass # skip non-frame files
26
  items.sort(key=lambda x: x[0])
27
  out, last = [], None
28
  for t, f in items: # drop a malformed duplicate-timestamp frame (would trip drive()'s assert)
src/humomni/core/validate_submission.py CHANGED
@@ -10,10 +10,6 @@ import sys
10
 
11
  def validate(path, gold_ids):
12
  seen, errs = set(), []
13
- if not gold_ids:
14
- # An empty gold set would validate ANY file (even an empty one) vacuously — that is
15
- # always a mispathed --data_dir, never a real check.
16
- return ["gold id list is empty — check --data_dir (expected data/phase1/data/<id>/)"]
17
  with open(path, encoding="utf-8") as f:
18
  for i, ln in enumerate(f, 1):
19
  ln = ln.strip()
 
10
 
11
  def validate(path, gold_ids):
12
  seen, errs = set(), []
 
 
 
 
13
  with open(path, encoding="utf-8") as f:
14
  for i, ln in enumerate(f, 1):
15
  ln = ln.strip()
src/humomni/tuning/cache.py CHANGED
@@ -53,12 +53,20 @@ class VLMCache:
53
  return len(self.mem)
54
 
55
 
 
 
 
 
 
 
 
 
 
 
56
  def frames_of(qid, data_dir="data/web_frames"):
57
  """Ascending (t, path) for a question_id (dir name drops the .mp4 suffix)."""
58
  name = qid[:-4] if qid.endswith(".mp4") else qid
59
- d = os.path.join(data_dir, name)
60
- return sorted([(float(os.path.basename(p)[:-4]), p) for p in glob.glob(d + "/*.jpg")],
61
- key=lambda x: x[0])
62
 
63
 
64
  def _sample_dirs(data_dir, limit):
@@ -77,8 +85,7 @@ def build_web_cache(data_dir="data/web_frames", cache_path=CACHE_PATH, limit=0,
77
  for d in _sample_dirs(data_dir, limit):
78
  q = json.load(open(os.path.join(d, "question.json"), encoding="utf-8"))
79
  qid, question = q["question_id"], q["question"]
80
- fr = sorted([(float(os.path.basename(p)[:-4]), p) for p in glob.glob(d + "/*.jpg")],
81
- key=lambda x: x[0])
82
  for i, (t, _) in enumerate(fr):
83
  if cache.get(qid, t) is None:
84
  tasks.append((qid, question, fr[:i + 1], t))
@@ -109,8 +116,7 @@ def build_novelty(data_dir="data/web_frames", out=NOVELTY_PATH, limit=0):
109
  res = {}
110
  for d in _sample_dirs(data_dir, limit):
111
  qid = json.load(open(os.path.join(d, "question.json"), encoding="utf-8"))["question_id"]
112
- fr = sorted([(float(os.path.basename(p)[:-4]), p) for p in glob.glob(d + "/*.jpg")],
113
- key=lambda x: x[0])
114
  prev, nov = None, {}
115
  for t, p in fr:
116
  f = np.asarray(Image.open(p).convert("L").resize((64, 36)), dtype=np.float32)
 
53
  return len(self.mem)
54
 
55
 
56
+ def _frames_in(d):
57
+ """Ascending (t, path) for every frame image in dir d — stems on the 0.5-s grid, e.g. 12.5.jpg.
58
+ Both extensions are supported: phase-1 test data ships .jpg frames, phase-2 ships .png."""
59
+ out = []
60
+ for pat in ("*.jpg", "*.png"):
61
+ for p in glob.glob(os.path.join(d, pat)):
62
+ out.append((float(os.path.basename(p)[:-4]), p))
63
+ return sorted(out, key=lambda x: x[0])
64
+
65
+
66
  def frames_of(qid, data_dir="data/web_frames"):
67
  """Ascending (t, path) for a question_id (dir name drops the .mp4 suffix)."""
68
  name = qid[:-4] if qid.endswith(".mp4") else qid
69
+ return _frames_in(os.path.join(data_dir, name))
 
 
70
 
71
 
72
  def _sample_dirs(data_dir, limit):
 
85
  for d in _sample_dirs(data_dir, limit):
86
  q = json.load(open(os.path.join(d, "question.json"), encoding="utf-8"))
87
  qid, question = q["question_id"], q["question"]
88
+ fr = _frames_in(d)
 
89
  for i, (t, _) in enumerate(fr):
90
  if cache.get(qid, t) is None:
91
  tasks.append((qid, question, fr[:i + 1], t))
 
116
  res = {}
117
  for d in _sample_dirs(data_dir, limit):
118
  qid = json.load(open(os.path.join(d, "question.json"), encoding="utf-8"))["question_id"]
119
+ fr = _frames_in(d)
 
120
  prev, nov = None, {}
121
  for t, p in fr:
122
  f = np.asarray(Image.open(p).convert("L").resize((64, 36)), dtype=np.float32)