43ntropy
/

43ntropy dawidtang commited on
Commit
1e2bb2f
·
0 Parent(s):

Duplicate from epfl-neuroai/NEvo

Browse files

Co-authored-by: david <dawidtang@users.noreply.huggingface.co>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +6 -0
  2. .gitattributes +41 -0
  3. .gitignore +6 -0
  4. README.md +213 -0
  5. assets/gallery/EBA.gif +3 -0
  6. assets/gallery/FFA.gif +3 -0
  7. assets/gallery/MT.gif +3 -0
  8. assets/gallery/PPA.gif +3 -0
  9. assets/gallery/V1.gif +3 -0
  10. assets/gallery/pSTS.gif +3 -0
  11. examples/override_models.py +13 -0
  12. examples/quickstart.py +11 -0
  13. examples/synthesize_for_roi.py +11 -0
  14. examples/synthesize_for_vector.py +9 -0
  15. model_index.json +5 -0
  16. pipeline.py +20 -0
  17. pyproject.toml +32 -0
  18. requirements.txt +17 -0
  19. run_regional_asset_pilot.py +271 -0
  20. run_roi_samples.py +273 -0
  21. stimulus_synthesis/__init__.py +8 -0
  22. stimulus_synthesis/asset_manifest.py +35 -0
  23. stimulus_synthesis/config.py +74 -0
  24. stimulus_synthesis/data/__init__.py +0 -0
  25. stimulus_synthesis/data/roi_masks.npz +3 -0
  26. stimulus_synthesis/data/searchlight_both.npz +3 -0
  27. stimulus_synthesis/data/searchlight_lh.npz +3 -0
  28. stimulus_synthesis/data/searchlight_rh.npz +3 -0
  29. stimulus_synthesis/generators/__init__.py +5 -0
  30. stimulus_synthesis/generators/base.py +23 -0
  31. stimulus_synthesis/generators/diffusers_i2v.py +78 -0
  32. stimulus_synthesis/generators/diffusers_t2i.py +39 -0
  33. stimulus_synthesis/media/__init__.py +22 -0
  34. stimulus_synthesis/media/asset_decode.py +88 -0
  35. stimulus_synthesis/media/asset_export.py +153 -0
  36. stimulus_synthesis/media/asset_spec.py +58 -0
  37. stimulus_synthesis/media/normalize.py +99 -0
  38. stimulus_synthesis/media/video_io.py +34 -0
  39. stimulus_synthesis/neuro/__init__.py +3 -0
  40. stimulus_synthesis/neuro/roi.py +110 -0
  41. stimulus_synthesis/outputs.py +26 -0
  42. stimulus_synthesis/paths.py +77 -0
  43. stimulus_synthesis/pipeline.py +420 -0
  44. stimulus_synthesis/scoring/__init__.py +26 -0
  45. stimulus_synthesis/scoring/asset_scorer.py +111 -0
  46. stimulus_synthesis/scoring/base.py +10 -0
  47. stimulus_synthesis/scoring/encoder_preprocess.py +115 -0
  48. stimulus_synthesis/scoring/encoder_scorer.py +50 -0
  49. stimulus_synthesis/scoring/objectives.py +64 -0
  50. stimulus_synthesis/scoring/robust_transform.py +97 -0
.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Optional cache-directory override for HuggingFace/torch downloads and run outputs.
2
+ # Cache resolution priority:
3
+ # 1. NEVO_CACHE_DIR below (if set)
4
+ # 2. the system/user-default HuggingFace cache (HF_HOME, else ~/.cache/huggingface)
5
+ # 3. <repo>/cache/ (only if no default is resolvable)
6
+ # NEVO_CACHE_DIR=/absolute/path/to/nevo_cache
.gitattributes ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/gallery/EBA.gif filter=lfs diff=lfs merge=lfs -text
37
+ assets/gallery/FFA.gif filter=lfs diff=lfs merge=lfs -text
38
+ assets/gallery/MT.gif filter=lfs diff=lfs merge=lfs -text
39
+ assets/gallery/PPA.gif filter=lfs diff=lfs merge=lfs -text
40
+ assets/gallery/V1.gif filter=lfs diff=lfs merge=lfs -text
41
+ assets/gallery/pSTS.gif filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .DS_Store
5
+ cache/
6
+ .env
README.md ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ tags:
3
+ - neuroscience
4
+ - fmri
5
+ - brain-decoding
6
+ - stimulus-synthesis
7
+ - v-jepa
8
+ - diffusers
9
+ library_name: diffusers
10
+ pipeline_tag: text-to-video
11
+ ---
12
+
13
+ > 🚧 **Work in progress** — this model is still being transferred from its main development repository, so the model card and API are subject to change.
14
+
15
+ # NEvo — Neural-Guided Evolutionary Video Synthesis
16
+
17
+ **🌐 Project website: [nevo-project.epfl.ch](https://nevo-project.epfl.ch/) · 📄 Paper: [arXiv:2607.02317](https://arxiv.org/abs/2607.02317)**
18
+
19
+ NEvo is a self-contained Hugging Face custom [Diffusers](https://github.com/huggingface/diffusers) pipeline for **neural-response-guided visual stimulus synthesis**. Given a brain target (a set of voxels, or a target fMRI vector), it searches over prompts, generates images and short videos, scores each candidate with a differentiable image/video→fMRI encoder, and returns the ranked stimuli predicted to best drive that target.
20
+
21
+ It orchestrates three frozen models. The models below are **placeholders / defaults** and can be swapped for any compatible models (weights are not bundled — they are pulled from their own repos):
22
+
23
+ | Role | Default model |
24
+ |------|---------------|
25
+ | Encoder (image/video → fMRI) | [`epfl-neuroai/vjepa2-encoder-basic`](https://huggingface.co/epfl-neuroai/vjepa2-encoder-basic) (`predict_fmri`) |
26
+ | Text → image | [`stabilityai/sdxl-turbo`](https://huggingface.co/stabilityai/sdxl-turbo) |
27
+ | Image → video | [`Lightricks/LTX-Video-0.9.8-13B-distilled`](https://huggingface.co/Lightricks/LTX-Video-0.9.8-13B-distilled) |
28
+
29
+ ## Gallery
30
+
31
+ Each clip is from the **top results of a NEvo search targeting one visual region** — the model discovers, from scratch, stimuli that drive that region's known selectivity.
32
+
33
+ | Region | Stimulus | Region | Stimulus |
34
+ |:------:|:--------:|:------:|:--------:|
35
+ | **FFA** · faces | ![FFA](assets/gallery/FFA.gif) | **PPA** · places | ![PPA](assets/gallery/PPA.gif) |
36
+ | **MT** · motion | ![MT](assets/gallery/MT.gif) | **EBA** · bodies | ![EBA](assets/gallery/EBA.gif) |
37
+ | **pSTS** · social motion | ![pSTS](assets/gallery/pSTS.gif) | **V1** · early visual | ![V1](assets/gallery/V1.gif) |
38
+
39
+ Explore the full interactive gallery and 3D brain maps at **[nevo-project.epfl.ch](https://nevo-project.epfl.ch/)**.
40
+
41
+ ## Installation
42
+
43
+ **Off-the-shelf — no install.** Load NEvo as a custom Diffusers pipeline; the package and its bundled data are fetched from the Hub automatically (you only need the usual dependencies below):
44
+
45
+ ```python
46
+ from diffusers import DiffusionPipeline
47
+
48
+ pipe = DiffusionPipeline.from_pretrained(
49
+ "epfl-neuroai/NEvo", custom_pipeline="epfl-neuroai/NEvo", trust_remote_code=True,
50
+ )
51
+ ```
52
+
53
+ **Or install the package** (for cleaner `from stimulus_synthesis import ...` imports / development):
54
+
55
+ ```bash
56
+ conda create -n nevo python=3.10 -y
57
+ conda activate nevo
58
+ pip install "git+https://huggingface.co/epfl-neuroai/NEvo"
59
+ # or from a local clone:
60
+ # git clone https://huggingface.co/epfl-neuroai/NEvo && pip install ./NEvo
61
+ # then: from stimulus_synthesis import NevoPipeline; pipe = NevoPipeline.from_pretrained("epfl-neuroai/NEvo")
62
+ ```
63
+
64
+ Runtime dependencies (either way): `torch`, `diffusers`, `transformers`, `huggingface_hub`, `numpy`, `pillow`, `av` (`pytest` for tests). No `nilearn` / atlas downloads — ROI masks are shipped as small precomputed data files.
65
+
66
+ ## Quickstart
67
+
68
+ Target a brain region by name — NEvo resolves its voxels and searches for a video predicted to drive it:
69
+
70
+ ```python
71
+ from diffusers import DiffusionPipeline
72
+
73
+ # fetches the pipeline (and package) from the Hub; model weights are pulled on first use
74
+ pipe = DiffusionPipeline.from_pretrained(
75
+ "epfl-neuroai/NEvo", custom_pipeline="epfl-neuroai/NEvo", trust_remote_code=True,
76
+ )
77
+
78
+ out = pipe(roi="FFA", progress=True) # omit seed (default) -> different result each run; pass seed=<int> to reproduce (the seed used is in out.metadata["seed"])
79
+ print(out.best_prompt, out.best_score)
80
+
81
+ from stimulus_synthesis.media import save_video, video_to_t_c_h_w # importable once the pipeline has loaded
82
+ save_video(video_to_t_c_h_w(out.best.video), "best_stimulus.mp4") # save the synthesized video
83
+ out.best.image.save("best_stimulus.png") # and the stage-1 best image (PIL)
84
+ ```
85
+
86
+ This runs the two-stage search with the defaults — up to 400 image evaluations then 200 video evaluations (population 20), using the fast distilled-model defaults (1-step 512×512 SDXL-Turbo, 8-step 512×512 LTX). A run takes a few minutes and a good amount of GPU memory.
87
+
88
+ ### Faster run
89
+
90
+ For a quicker first result, shrink the search and the video:
91
+
92
+ ```python
93
+ out = pipe(
94
+ roi="FFA",
95
+ progress=True,
96
+ image_max_evals=80, # stage-1 (image) evaluation budget (default: 400)
97
+ video_max_evals=40, # stage-2 (video) evaluation budget (default: 200)
98
+ population_size=8, # GA population per generation (default: 20)
99
+ seed=0, # RNG seed, for reproducibility
100
+ video_kwargs={ # merged over the fast defaults (8 steps / 25 frames / 512²); override any key
101
+ "num_inference_steps": 8, # denoising steps — the distilled LTX model needs only a few
102
+ "num_frames": 25, # clip length; LTX requires 8*k + 1 frames
103
+ "height": 256, "width": 256,
104
+ },
105
+ )
106
+ ```
107
+
108
+ **Enhanced search space.** Selecting a region (`roi=...`) restricts the prompt search to the categories relevant to that region — a smaller space that converges faster. Pass `enforce_general_search_space=True` to search the full general space instead.
109
+
110
+ Available ROI tokens (comma-separated tokens are unioned):
111
+
112
+ - **Named ROIs:** `FFA`, `PPA`, `MT`, `EBA`, `LOC`, `RSC`, `pSTS`, `aSTS`, `V1`, `V2`, `V3`, `V4` — optionally hemisphere-suffixed (`FFA_lh`, `MT_rh`).
113
+ - **Searchlight regions:** `SL-<n>` (both hemispheres), `SL-<n>_lh`, `SL-<n>_rh` (58 both / 28 lh / 30 rh).
114
+
115
+ ```python
116
+ from stimulus_synthesis.neuro import available_rois, searchlight_counts
117
+ available_rois() # ['EBA','FFA','LOC','MT','PPA','RSC','V1','V2','V3','V4','aSTS','pSTS']
118
+ searchlight_counts() # {'both': 58, 'lh': 28, 'rh': 30}
119
+ ```
120
+
121
+ > **fsaverage5 only.** The bundled ROI/searchlight masks are defined on the **fsaverage5** cortical surface (20 484 vertices). Targeting a region by name therefore requires an encoder whose `predict_fmri` output lives in that same space — the default `epfl-neuroai/vjepa2-encoder-basic`. A custom encoder with a different output space can still be driven with explicit `vector`/`indices` targets, but not with the named-ROI helper.
122
+
123
+ ### Custom targets
124
+
125
+ Instead of a region name, pass raw voxel indices or a full target fMRI vector:
126
+
127
+ ```python
128
+ import numpy as np
129
+ from stimulus_synthesis import resolve_driving_voxels
130
+
131
+ mask = resolve_driving_voxels("FFA") # boolean mask, length 20484
132
+ out = pipe(target={"type": "indices", "indices": np.flatnonzero(mask).tolist()})
133
+ ```
134
+
135
+ ### Target types & objectives
136
+
137
+ | Target | Objective (default) | Meaning |
138
+ |--------|--------------------|---------|
139
+ | `{"type": "indices", "indices": [...]}` | `indices_mean` | mean predicted response over ROI voxels |
140
+ | `{"type": "vector", "vector": [...]}` (len 20484) | `target_vector_cosine` / `vector_dot` | match a full target fMRI vector |
141
+ | `{"type": "weights", "weights": [...]}` | `weighted_mean` | weighted voxel objective |
142
+
143
+ ## Search parameters (defaults)
144
+
145
+ Set in `stimulus_synthesis_config.json`:
146
+
147
+ | Param | Default | Notes |
148
+ |-------|---------|-------|
149
+ | `default_image_max_evals` | 400 | stage-1 (image) evaluation budget (GA `max_evals`) |
150
+ | `default_video_max_evals` | 200 | stage-2 (video) evaluation budget |
151
+ | `default_population_size` | 20 | GA population per generation (= `n_init`) |
152
+ | `default_score_frames` | 24 | number of frames the encoder scores (a still image is replicated to this) |
153
+ | `default_score_size` | 224 | resolution the clip is resized to for the encoder (call-time: `score_size=`) |
154
+ | `default_mutation_rate` | 0.25 | |
155
+ | `default_elite_frac` | 0.35 | |
156
+ | `default_objective` | `indices_mean` | |
157
+ | `default_score_transform` | disabled | robust augmentation off by default (clean single pass) |
158
+ | `default_image_kwargs` | `{num_inference_steps: 1, guidance_scale: 0, height: 512, width: 512}` | fast SDXL-Turbo settings (merged under call-time `image_kwargs`) |
159
+ | `default_video_kwargs` | `{num_inference_steps: 8, num_frames: 25, height: 512, width: 512}` | fast LTX settings (merged under call-time `video_kwargs`) |
160
+
161
+ Each stage runs a genetic search with population `population_size` (default 20) until it hits its evaluation budget — `image_max_evals` (default 400) and `video_max_evals` (default 200) generate→score passes. Image and video generation use fast distilled defaults out of the box (`default_image_kwargs` / `default_video_kwargs`); anything you pass as `image_kwargs` / `video_kwargs` is merged over them, so you only override the keys you care about.
162
+
163
+ ### Robust scoring
164
+
165
+ By default each candidate is scored with a single clean encoder pass. An optional **robust mode** — the mean over 4 augmented draws (random crop `0.8`, Gaussian `σ=0.1`) via `RobustTransformScorer` — reduces sensitivity to encoder artifacts; turn it on by setting `"enabled": true` in `default_score_transform`.
166
+
167
+ ## Cache configuration
168
+
169
+ Model weights and outputs cache location resolves in priority order:
170
+
171
+ 1. `NEvo_CACHE_DIR` — set it in a repo-root `.env` file (see `.env.example`) or the environment.
172
+ 2. Otherwise the **system/user-default HuggingFace cache** (`HF_HOME`, else `~/.cache/huggingface`) is used and left untouched.
173
+ 3. Only if no default is resolvable, a repo-local `cache/` is used.
174
+
175
+ `cache/` and `.env` are git-ignored.
176
+
177
+ ## Batch runners
178
+
179
+ Two ROI-driven, two-stage (image-search → video-search) runners are included:
180
+
181
+ - **`run_roi_samples.py`** — genetic search per ROI/seed, scoring in-memory tensors; writes `best_image.png` / `best_video.mp4` / scores.
182
+ - **`run_regional_asset_pilot.py`** — same search but exports every candidate to a deterministically-encoded file (PNG/MP4), hashes it (sha256), and scores the *decoded file* — producing provenance-tracked, reproducible published assets with manifests.
183
+
184
+ Both take `--rois`, `--seeds`, `--image-evals` / `--video-evals`, `--encoder-model`, `--out-dir`, etc., and default to the config's encoder and a cache-relative output directory.
185
+
186
+ ## Reproducibility
187
+
188
+ The pipeline is deterministic for a fixed seed/config: the shipped ROI masks reproduce the original atlas masks bit-for-bit, and a fixed-seed run reproduces prior scores exactly. Encoder scores are a *target-matching* signal, not ground-truth reconstruction quality.
189
+
190
+ ## Intended use & limitations
191
+
192
+ - **Research use** in visual neuroscience / brain-decoding. Outputs are *predicted* to drive a target region under a specific encoder — they are hypotheses to validate, not ground truth.
193
+ - Optimizing hard against a single encoder can exploit encoder artifacts; inspect images and use held-out validation.
194
+ - Requires a CUDA GPU with enough memory for the 13B video model; you must accept the license/access terms of the referenced upstream models.
195
+
196
+ ## Citation
197
+
198
+ If you use NEvo, please cite:
199
+
200
+ ```bibtex
201
+ @article{tang2026nevo,
202
+ title={NEvo: Neural-Guided Evolutionary Video Synthesis for Dynamic Visual Selectivity},
203
+ author={Tang, Yingtian and Salehi, Sogand and Zhou, Ming and Zamir, Amir and Isik, Leyla and Schrimpf, Martin},
204
+ journal={arXiv preprint arXiv:2607.02317},
205
+ year={2026}
206
+ }
207
+ ```
208
+
209
+ Project website: [nevo-project.epfl.ch](https://nevo-project.epfl.ch/)
210
+
211
+ ## Acknowledgements
212
+
213
+ Builds on BrainDiVE-style encoder-guided synthesis, vJEPA-2, SDXL-Turbo, and LTX-Video. ROI/searchlight definitions derive from an fsaverage-space group atlas (precomputed and bundled).
assets/gallery/EBA.gif ADDED

Git LFS Details

  • SHA256: 683bbc7d810684fbb404d40e33138b1bd006337604e4d3fe099ce2514fb1e13f
  • Pointer size: 132 Bytes
  • Size of remote file: 1.01 MB
assets/gallery/FFA.gif ADDED

Git LFS Details

  • SHA256: 4fed64effefc8fc7f344f93e6b4d9f1e49a1cd7907d6844d9f4fff43eeacf4ab
  • Pointer size: 131 Bytes
  • Size of remote file: 845 kB
assets/gallery/MT.gif ADDED

Git LFS Details

  • SHA256: 5089ec8bb2513a5601896b585c9b10d96502b8ecb392fe4b9fd43889d37ecd0b
  • Pointer size: 132 Bytes
  • Size of remote file: 1.21 MB
assets/gallery/PPA.gif ADDED

Git LFS Details

  • SHA256: 3e0be6ceec22838fd2ade0f0c505f2136e87b70c9a90235dcb0a43eb7c5b51a1
  • Pointer size: 132 Bytes
  • Size of remote file: 1.07 MB
assets/gallery/V1.gif ADDED

Git LFS Details

  • SHA256: d4ceee8ad5903bb98b602a231f5bd2b8c5fc1bf6cbb36998a39a3ae2b5be2d13
  • Pointer size: 132 Bytes
  • Size of remote file: 1.08 MB
assets/gallery/pSTS.gif ADDED

Git LFS Details

  • SHA256: 17f5f6992c66447468f896d6d5ba6f7334e67acbe8777163d27f53e9e44a4746
  • Pointer size: 131 Bytes
  • Size of remote file: 831 kB
examples/override_models.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from stimulus_synthesis import NevoPipeline
2
+
3
+ pipe = NevoPipeline.from_pretrained(
4
+ "epfl-neuroai/NEvo",
5
+ synthesis_config={
6
+ "text_to_image_model_id": "stabilityai/sdxl-turbo",
7
+ "image_to_video_model_id": "Lightricks/LTX-Video-0.9.8-13B-distilled",
8
+ "encoder_model_id": "epfl-neuroai/vjepa2-encoder-basic",
9
+ },
10
+ )
11
+
12
+ out = pipe(target={"type": "indices", "indices": [0, 1, 2]}, seed_prompts=["a moving person"])
13
+ print(out.best_prompt, out.best_score)
examples/quickstart.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from stimulus_synthesis import NevoPipeline
2
+
3
+ pipe = NevoPipeline.from_pretrained("epfl-neuroai/NEvo")
4
+
5
+ out = pipe(
6
+ target={"type": "indices", "indices": [0, 1, 2]},
7
+ seed_prompts=["a person running through a crowded street"],
8
+ num_candidates=16,
9
+ num_rounds=50,
10
+ )
11
+ print(out.best_prompt, out.best_score)
examples/synthesize_for_roi.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from stimulus_synthesis import NevoPipeline, resolve_driving_voxels
3
+
4
+ pipe = NevoPipeline.from_pretrained("epfl-neuroai/NEvo")
5
+
6
+ mask = resolve_driving_voxels("FFA") # boolean mask, length 20484
7
+ out = pipe(
8
+ target={"type": "indices", "indices": np.flatnonzero(mask).tolist()},
9
+ seed_prompts=["a close-up of a person's face"],
10
+ )
11
+ print(out.best_prompt, out.best_score)
examples/synthesize_for_vector.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from stimulus_synthesis import NevoPipeline
3
+
4
+ pipe = NevoPipeline.from_pretrained("epfl-neuroai/NEvo")
5
+
6
+ target_vector = torch.zeros(20484)
7
+ target_vector[:10] = 1.0
8
+ out = pipe(target={"type": "vector", "vector": target_vector.tolist()}, seed_prompts=["natural action video"])
9
+ print(out.best_prompt, out.best_score)
model_index.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "_class_name": "NevoPipeline",
3
+ "_diffusers_version": "0.30.0",
4
+ "_module": "pipeline"
5
+ }
pipeline.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NEvo custom Diffusers pipeline entry point.
2
+
3
+ Works both when the ``stimulus_synthesis`` package is installed (``pip install``) and,
4
+ off-the-shelf, when the pipeline is loaded via
5
+ ``DiffusionPipeline.from_pretrained(repo, custom_pipeline=repo, trust_remote_code=True)``
6
+ without installing anything — in that case the package (and its bundled data) is
7
+ fetched from the Hub and put on the import path.
8
+ """
9
+ try:
10
+ from stimulus_synthesis.pipeline import NevoPipeline
11
+ except ModuleNotFoundError:
12
+ import sys
13
+ from huggingface_hub import snapshot_download
14
+
15
+ _pkg_root = snapshot_download("epfl-neuroai/NEvo", allow_patterns=["stimulus_synthesis/**"])
16
+ if _pkg_root not in sys.path:
17
+ sys.path.insert(0, _pkg_root)
18
+ from stimulus_synthesis.pipeline import NevoPipeline
19
+
20
+ __all__ = ["NevoPipeline"]
pyproject.toml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nevo-stimulus-synthesis"
7
+ version = "0.1.0"
8
+ description = "NEVO: neural-response-guided visual stimulus synthesis (custom Diffusers pipeline)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "torch",
13
+ "diffusers",
14
+ "transformers",
15
+ "huggingface_hub",
16
+ "numpy",
17
+ "pillow",
18
+ "av",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ test = ["pytest"]
23
+
24
+ [tool.setuptools.packages.find]
25
+ include = ["stimulus_synthesis*"]
26
+
27
+ [tool.setuptools.package-data]
28
+ "stimulus_synthesis.data" = ["*.npz"]
29
+
30
+ [tool.pytest.ini_options]
31
+ pythonpath = ["."]
32
+ testpaths = ["tests"]
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NEVO stimulus-synthesis — minimal runtime dependencies
2
+ torch
3
+ diffusers
4
+ transformers
5
+ huggingface_hub
6
+ numpy
7
+ pillow
8
+ av
9
+ tqdm
10
+ tiktoken
11
+ sentencepiece
12
+ protobuf
13
+ timm
14
+ einops
15
+
16
+ # test-only
17
+ pytest
run_regional_asset_pilot.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import gc
5
+ import json
6
+ import shutil
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ import torch
14
+ from PIL import Image
15
+
16
+ _REPO = Path(__file__).resolve().parent
17
+ if str(_REPO) not in sys.path:
18
+ sys.path.insert(0, str(_REPO))
19
+
20
+ from stimulus_synthesis.spaces import StructuredArtPromptSpace, VideoMotionPromptSpace, make_t2v_art_data
21
+ from stimulus_synthesis.neuro import resolve_driving_voxels
22
+ from stimulus_synthesis.config import StimulusSynthesisConfig
23
+ from stimulus_synthesis.paths import get_cache_dir
24
+ from stimulus_synthesis.asset_manifest import write_asset_manifest
25
+ from stimulus_synthesis.generators.diffusers_i2v import DiffusersImageToVideoAdapter
26
+ from stimulus_synthesis.generators.diffusers_t2i import DiffusersTextToImageAdapter
27
+ from stimulus_synthesis.media import AssetExportRecord, ImageAssetSpec, VideoAssetSpec, sha256_file
28
+ from stimulus_synthesis.scoring import AssetScorer, EncoderPreprocessSpec
29
+ from stimulus_synthesis.scoring.encoder_scorer import EncoderScorer
30
+ from stimulus_synthesis.search.genetic import GeneticSearch
31
+ from stimulus_synthesis.spaces import SeededSearchSpace
32
+
33
+
34
+ class StaticImageToVideo:
35
+ def generate(self, image: Any, prompt: str, **kwargs) -> Any:
36
+ return image
37
+
38
+ def generate_batch(self, images: list[Any], prompts: list[str], **kwargs) -> list[Any]:
39
+ return list(images)
40
+
41
+
42
+ class FixedImageT2I:
43
+ def __init__(self, image: Any):
44
+ self.image = image
45
+
46
+ def generate(self, prompts: list[str], **kwargs) -> list[Any]:
47
+ return [self.image for _ in prompts]
48
+
49
+
50
+ def seed_values(run_seed: int, count: int, *, stream: int) -> list[int]:
51
+ rng = np.random.default_rng(int(run_seed) + 1_000_003 * int(stream))
52
+ return [int(x) for x in rng.integers(0, 2**31 - 1, size=int(count), dtype=np.int64)]
53
+
54
+
55
+ def copy_exported_asset(record: AssetExportRecord, path: Path, spec: ImageAssetSpec | VideoAssetSpec) -> AssetExportRecord:
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ shutil.copy2(record.path, path)
58
+ return AssetExportRecord(
59
+ path=str(path),
60
+ asset_type=record.asset_type,
61
+ sha256=sha256_file(path),
62
+ bytes=path.stat().st_size,
63
+ spec=spec.to_dict(),
64
+ )
65
+
66
+
67
+ def run_one(roi: str, seed: int, args, t2i_base, i2v_base, scorer) -> dict[str, Any]:
68
+ seed_dir = Path(args.out_dir) / roi / f'seed_{seed:06d}'
69
+ seed_dir.mkdir(parents=True, exist_ok=True)
70
+ result_path = seed_dir / 'result.json'
71
+ if result_path.exists() and not args.overwrite:
72
+ print(f'[skip] {roi} seed={seed}: {result_path} exists', flush=True)
73
+ return json.loads(result_path.read_text())
74
+
75
+ voxels = resolve_driving_voxels(roi)
76
+ indices = np.flatnonzero(voxels).astype(int).tolist()
77
+ target = {'type': 'indices', 'indices': indices}
78
+ print(f'[start] roi={roi} seed={seed} voxels={len(indices)}', flush=True)
79
+
80
+ image_kwargs = {
81
+ 'height': args.image_height,
82
+ 'width': args.image_width,
83
+ 'num_inference_steps': 1,
84
+ 'guidance_scale': 0.0,
85
+ }
86
+ image_spec = ImageAssetSpec(width=args.image_width, height=args.image_height, format='png')
87
+ video_spec = VideoAssetSpec(width=args.video_width, height=args.video_height, fps=args.fps, num_frames=args.video_frames, crf=args.video_crf)
88
+ asset_scorer = AssetScorer(scorer, target, preprocess_spec=EncoderPreprocessSpec(size=args.score_size, num_frames=args.score_frames))
89
+
90
+ image_space = SeededSearchSpace(
91
+ StructuredArtPromptSpace(art_data=make_t2v_art_data(), roi=roi, option_embeddings=None),
92
+ seed_values(seed, args.seed_gene_count, stream=0),
93
+ )
94
+ image_search = GeneticSearch(
95
+ max_evals=args.image_evals,
96
+ population_size=args.image_population,
97
+ n_init=args.image_population,
98
+ mutation_rate=args.mutation_rate,
99
+ crossover_rate=args.crossover_rate,
100
+ elite_frac=args.elite_frac,
101
+ image_kwargs=image_kwargs,
102
+ video_kwargs={},
103
+ score_kwargs={},
104
+ video_size=args.score_size,
105
+ num_frames=args.score_frames,
106
+ asset_scorer=asset_scorer,
107
+ asset_dir=seed_dir / 'candidate_images',
108
+ asset_type='image',
109
+ image_asset_spec=image_spec,
110
+ )
111
+ t0 = time.time()
112
+ image_result = image_search.run(image_space, t2i_base, StaticImageToVideo(), scorer, target, seed=seed)
113
+ image_record = copy_exported_asset(image_result.best_export_record, seed_dir / 'best_image.png', image_spec)
114
+ final_image_score = asset_scorer.score_image(
115
+ image_record.path,
116
+ asset_spec=image_spec,
117
+ metadata={'roi': roi, 'run_seed': seed, 'generation_seed': image_result.best_seed, 'prompt': image_result.best_prompt},
118
+ )
119
+ best_image = Image.open(image_record.path).convert('RGB')
120
+ np.save(seed_dir / 'image_history_best.npy', np.asarray(image_result.history_best, dtype=np.float32))
121
+ print(f'[image done] roi={roi} seed={seed} asset_score={image_result.best_score:.6f} seconds={time.time()-t0:.1f}', flush=True)
122
+
123
+ video_kwargs = {
124
+ 'height': args.video_height,
125
+ 'width': args.video_width,
126
+ 'num_frames': args.video_frames,
127
+ 'frame_rate': args.fps,
128
+ 'num_inference_steps': args.video_steps,
129
+ 'guidance_scale': args.video_guidance_scale,
130
+ 'output_type': 'np',
131
+ }
132
+ video_space = SeededSearchSpace(
133
+ VideoMotionPromptSpace(roi=roi, option_embeddings=None),
134
+ seed_values(seed, args.seed_gene_count, stream=1),
135
+ )
136
+ video_search = GeneticSearch(
137
+ max_evals=args.video_evals,
138
+ population_size=args.video_population,
139
+ n_init=min(args.video_population, args.video_evals),
140
+ mutation_rate=args.mutation_rate,
141
+ crossover_rate=args.crossover_rate,
142
+ elite_frac=args.elite_frac,
143
+ image_kwargs={},
144
+ video_kwargs=video_kwargs,
145
+ score_kwargs={},
146
+ video_size=args.score_size,
147
+ num_frames=args.score_frames,
148
+ asset_scorer=asset_scorer,
149
+ asset_dir=seed_dir / 'candidate_videos',
150
+ asset_type='video',
151
+ video_asset_spec=video_spec,
152
+ )
153
+ t1 = time.time()
154
+ video_result = video_search.run(video_space, FixedImageT2I(best_image), i2v_base, scorer, target, seed=seed)
155
+ video_record = copy_exported_asset(video_result.best_export_record, seed_dir / 'best_video.mp4', video_spec)
156
+ final_video_score = asset_scorer.score_video(
157
+ video_record.path,
158
+ asset_spec=video_spec,
159
+ metadata={'roi': roi, 'run_seed': seed, 'generation_seed': video_result.best_seed, 'prompt': video_result.best_prompt},
160
+ )
161
+ np.save(seed_dir / 'video_history_best.npy', np.asarray(video_result.history_best, dtype=np.float32))
162
+ print(f'[video done] roi={roi} seed={seed} asset_score={video_result.best_score:.6f} seconds={time.time()-t1:.1f}', flush=True)
163
+
164
+ manifest_path = seed_dir / 'asset_manifest.json'
165
+ write_asset_manifest([image_record, video_record, final_image_score, final_video_score], manifest_path, metadata={
166
+ 'roi': roi,
167
+ 'seed': seed,
168
+ 'num_voxels': len(indices),
169
+ 'text_to_image_model_id': args.text_to_image_model,
170
+ 'image_to_video_model_id': args.image_to_video_model,
171
+ 'encoder_model_id': args.encoder_model,
172
+ 'score_size': args.score_size,
173
+ 'score_frames': args.score_frames,
174
+ })
175
+
176
+ meta = {
177
+ 'roi': roi,
178
+ 'seed': seed,
179
+ 'num_voxels': len(indices),
180
+ 'image': {
181
+ 'max_evals': args.image_evals,
182
+ 'best_prompt': image_result.best_prompt,
183
+ 'optimization_score': image_result.best_score,
184
+ 'final_asset_score': final_image_score.score,
185
+ 'best_image': image_record.path,
186
+ 'sha256': image_record.sha256,
187
+ 'generation_seed': image_result.best_seed,
188
+ 'candidate_key': image_result.best_key,
189
+ 'score_source': image_result.best_metadata.get('score_source'),
190
+ },
191
+ 'video': {
192
+ 'max_evals': args.video_evals,
193
+ 'best_prompt': video_result.best_prompt,
194
+ 'optimization_score': video_result.best_score,
195
+ 'final_asset_score': final_video_score.score,
196
+ 'best_video': video_record.path,
197
+ 'sha256': video_record.sha256,
198
+ 'generation_seed': video_result.best_seed,
199
+ 'candidate_key': video_result.best_key,
200
+ 'score_source': video_result.best_metadata.get('score_source'),
201
+ 'sampled_frame_indices': final_video_score.sampled_frame_indices,
202
+ },
203
+ 'params': {
204
+ 'image_kwargs': image_kwargs,
205
+ 'video_kwargs': {k: v for k, v in video_kwargs.items() if k != 'output_type'},
206
+ 'video_crf': args.video_crf,
207
+ 'score_size': args.score_size,
208
+ 'score_frames': args.score_frames,
209
+ },
210
+ 'asset_manifest': str(manifest_path),
211
+ }
212
+ result_path.write_text(json.dumps(meta, indent=2))
213
+ print(f'[final asset] roi={roi} seed={seed} image={final_image_score.score:.6f} video={final_video_score.score:.6f}', flush=True)
214
+ return meta
215
+
216
+
217
+ def main() -> None:
218
+ p = argparse.ArgumentParser()
219
+ p.add_argument('--rois', nargs='+', default=['FFA', 'PPA', 'pSTS', 'MT'])
220
+ p.add_argument('--seeds', nargs='+', type=int, default=[101])
221
+ p.add_argument('--out-dir', default=str(get_cache_dir() / 'results' / 'hf_nevo_regional_asset_pilot'))
222
+ p.add_argument('--overwrite', action='store_true')
223
+ p.add_argument('--device', default='cuda')
224
+ p.add_argument('--text-to-image-model', default='stabilityai/sdxl-turbo')
225
+ p.add_argument('--image-to-video-model', default='Lightricks/LTX-Video-0.9.8-13B-distilled')
226
+ p.add_argument('--encoder-model', default=StimulusSynthesisConfig().encoder_model_id)
227
+ p.add_argument('--image-evals', type=int, default=24)
228
+ p.add_argument('--video-evals', type=int, default=8)
229
+ p.add_argument('--image-population', type=int, default=8)
230
+ p.add_argument('--video-population', type=int, default=4)
231
+ p.add_argument('--mutation-rate', type=float, default=0.2)
232
+ p.add_argument('--crossover-rate', type=float, default=0.5)
233
+ p.add_argument('--elite-frac', type=float, default=0.3)
234
+ p.add_argument('--image-width', type=int, default=256)
235
+ p.add_argument('--image-height', type=int, default=256)
236
+ p.add_argument('--video-width', type=int, default=256)
237
+ p.add_argument('--video-height', type=int, default=256)
238
+ p.add_argument('--video-frames', type=int, default=17)
239
+ p.add_argument('--video-steps', type=int, default=4)
240
+ p.add_argument('--video-guidance-scale', type=float, default=1.0)
241
+ p.add_argument('--video-crf', type=int, default=10)
242
+ p.add_argument('--fps', type=int, default=24)
243
+ p.add_argument('--score-size', type=int, default=224)
244
+ p.add_argument('--score-frames', type=int, default=16)
245
+ p.add_argument('--seed-gene-count', type=int, default=64)
246
+ args = p.parse_args()
247
+
248
+ device = args.device if torch.cuda.is_available() else 'cpu'
249
+ args.device = device
250
+ Path(args.out_dir).mkdir(parents=True, exist_ok=True)
251
+ print(json.dumps({'stage': 'load_components', 'device': device, 'out_dir': args.out_dir}), flush=True)
252
+ t0 = time.time()
253
+ t2i_base = DiffusersTextToImageAdapter(args.text_to_image_model, device=device)
254
+ i2v_base = DiffusersImageToVideoAdapter(args.image_to_video_model, device=device)
255
+ scorer = EncoderScorer(args.encoder_model, encoder_call='predict_fmri', objective='indices_mean', device=device)
256
+ print(json.dumps({'stage': 'components_loaded', 'seconds': round(time.time() - t0, 1)}), flush=True)
257
+
258
+ all_meta = []
259
+ for roi in args.rois:
260
+ for seed in args.seeds:
261
+ all_meta.append(run_one(roi, seed, args, t2i_base, i2v_base, scorer))
262
+ gc.collect()
263
+ if torch.cuda.is_available():
264
+ torch.cuda.empty_cache()
265
+ summary_path = Path(args.out_dir) / 'summary.json'
266
+ summary_path.write_text(json.dumps(all_meta, indent=2))
267
+ print(json.dumps({'stage': 'done', 'summary': str(summary_path), 'runs': len(all_meta)}), flush=True)
268
+
269
+
270
+ if __name__ == '__main__':
271
+ main()
run_roi_samples.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+ import torch
13
+ from PIL import Image
14
+
15
+ _REPO = Path(__file__).resolve().parent
16
+ if str(_REPO) not in sys.path:
17
+ sys.path.insert(0, str(_REPO))
18
+
19
+ from stimulus_synthesis.spaces import ( # noqa: E402
20
+ StructuredArtPromptSpace,
21
+ VideoMotionPromptSpace,
22
+ make_t2v_art_data,
23
+ )
24
+ from stimulus_synthesis.neuro import resolve_driving_voxels # noqa: E402
25
+ from stimulus_synthesis.generators.diffusers_t2i import DiffusersTextToImageAdapter # noqa: E402
26
+ from stimulus_synthesis.generators.diffusers_i2v import DiffusersImageToVideoAdapter # noqa: E402
27
+ from stimulus_synthesis.media.normalize import video_to_t_c_h_w # noqa: E402
28
+ from stimulus_synthesis.media.video_io import save_video # noqa: E402
29
+ from stimulus_synthesis.scoring.encoder_scorer import EncoderScorer # noqa: E402
30
+ from stimulus_synthesis.search.genetic import GeneticSearch # noqa: E402
31
+ from stimulus_synthesis.config import StimulusSynthesisConfig # noqa: E402
32
+ from stimulus_synthesis.paths import get_cache_dir # noqa: E402
33
+
34
+
35
+ def _vkw(args):
36
+ kw = {"height": args.video_height, "width": args.video_width, "num_frames": args.video_frames}
37
+ if getattr(args, "video_steps", 0) and args.video_steps > 0:
38
+ kw["num_inference_steps"] = int(args.video_steps)
39
+ return kw
40
+
41
+
42
+ class SeededTextToImage:
43
+ def __init__(self, inner: DiffusersTextToImageAdapter, seed: int):
44
+ self.inner = inner
45
+ self.seed = int(seed)
46
+
47
+ def generate(self, prompts: list[str], **kwargs) -> list[Any]:
48
+ torch.manual_seed(self.seed)
49
+ if torch.cuda.is_available():
50
+ torch.cuda.manual_seed_all(self.seed)
51
+ return self.inner.generate(prompts, **kwargs)
52
+
53
+
54
+ class StaticImageToVideo:
55
+ def generate(self, image: Image.Image, prompt: str, **kwargs) -> Image.Image:
56
+ return image
57
+
58
+ def generate_batch(self, images: list[Any], prompts: list[str], **kwargs) -> list[Any]:
59
+ return list(images)
60
+
61
+
62
+ class SeededImageToVideo:
63
+ def __init__(self, inner: DiffusersImageToVideoAdapter, seed: int):
64
+ self.inner = inner
65
+ self.seed = int(seed)
66
+ self.counter = 0
67
+
68
+ def generate(self, image: Any, prompt: str, **kwargs) -> Any:
69
+ kwargs.pop("generator", None) # override any caller-supplied generator with the seeded one
70
+ seed = self.seed + self.counter
71
+ self.counter += 1
72
+ generator = None
73
+ if torch.cuda.is_available():
74
+ generator = torch.Generator(device="cuda").manual_seed(seed)
75
+ else:
76
+ generator = torch.Generator().manual_seed(seed)
77
+ return self.inner.generate(image, prompt, generator=generator, **kwargs)
78
+
79
+ def generate_batch(self, images: list[Any], prompts: list[str], **kwargs) -> list[Any]:
80
+ return [self.generate(image, prompt, **kwargs) for image, prompt in zip(images, prompts)]
81
+
82
+
83
+ def save_image(image: Any, path: Path) -> None:
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+ if isinstance(image, Image.Image):
86
+ image.save(path)
87
+ return
88
+ if torch.is_tensor(image):
89
+ x = image.detach().cpu().float().clamp(0, 1)
90
+ if x.ndim == 4:
91
+ x = x[0]
92
+ if x.ndim == 3 and x.shape[0] in (1, 3):
93
+ arr = (x.permute(1, 2, 0).numpy() * 255).astype(np.uint8)
94
+ Image.fromarray(arr).save(path)
95
+ return
96
+ raise TypeError(f"Unsupported image type for saving: {type(image)!r}")
97
+
98
+
99
+ def save_any_video(video: Any, path: Path, fps: int = 24) -> None:
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ tensor = video_to_t_c_h_w(video).clamp(0, 1)
102
+ save_video(tensor, str(path), fps=fps)
103
+
104
+
105
+ def best_artifacts_from_search(search: GeneticSearch, space, t2i, i2v, scorer, target, seed: int, image_kwargs, video_kwargs, score_kwargs):
106
+ result = search.run(space, t2i, i2v, scorer, target, seed=seed)
107
+ image = t2i.generate([result.best_prompt], **image_kwargs)[0]
108
+ video = i2v.generate(image, result.best_prompt, **video_kwargs)
109
+ return result, image, video
110
+
111
+
112
+ def run_one(roi: str, seed: int, args, t2i_base, i2v_base, scorer) -> dict:
113
+ seed_dir = Path(args.out_dir) / roi / f"seed_{seed:06d}"
114
+ seed_dir.mkdir(parents=True, exist_ok=True)
115
+ done = seed_dir / "result.json"
116
+ if done.exists() and not args.overwrite:
117
+ print(f"[skip] {roi} seed={seed}: {done} exists", flush=True)
118
+ return json.loads(done.read_text())
119
+
120
+ voxels = resolve_driving_voxels(roi)
121
+ indices = np.flatnonzero(voxels).astype(int).tolist()
122
+ target = {"type": "indices", "indices": indices}
123
+ print(f"[start] {roi} seed={seed} voxels={len(indices)}", flush=True)
124
+
125
+ image_space = StructuredArtPromptSpace(art_data=make_t2v_art_data(), roi=roi, option_embeddings=None)
126
+ image_search = GeneticSearch(
127
+ max_evals=args.image_evals,
128
+ population_size=args.image_population,
129
+ n_init=args.image_population,
130
+ mutation_rate=args.mutation_rate,
131
+ crossover_rate=args.crossover_rate,
132
+ elite_frac=args.elite_frac,
133
+ image_kwargs={"num_inference_steps": 1, "guidance_scale": 0.0},
134
+ video_kwargs={},
135
+ score_kwargs={},
136
+ video_size=args.score_size,
137
+ num_frames=args.score_frames,
138
+ )
139
+
140
+ t2i = SeededTextToImage(t2i_base, seed)
141
+ image_result, best_image, _static_video = best_artifacts_from_search(
142
+ image_search,
143
+ image_space,
144
+ t2i,
145
+ StaticImageToVideo(),
146
+ scorer,
147
+ target,
148
+ seed,
149
+ {"num_inference_steps": 1, "guidance_scale": 0.0},
150
+ {},
151
+ {},
152
+ )
153
+ best_image_path = seed_dir / "best_image.png"
154
+ save_image(best_image, best_image_path)
155
+ np.save(seed_dir / "image_history_best.npy", np.asarray(image_result.history_best, dtype=np.float32))
156
+ (seed_dir / "image_result.json").write_text(json.dumps({
157
+ "roi": roi,
158
+ "seed": seed,
159
+ "num_voxels": len(indices),
160
+ "best_prompt": image_result.best_prompt,
161
+ "best_score": image_result.best_score,
162
+ "best_image": str(best_image_path),
163
+ }, indent=2))
164
+ print(f"[image done] {roi} seed={seed} score={image_result.best_score:.6f}", flush=True)
165
+
166
+ video_space = VideoMotionPromptSpace(roi=roi, option_embeddings=None)
167
+ video_search = GeneticSearch(
168
+ max_evals=args.video_evals,
169
+ population_size=args.video_population,
170
+ n_init=min(args.video_population, args.video_evals),
171
+ mutation_rate=args.mutation_rate,
172
+ crossover_rate=args.crossover_rate,
173
+ elite_frac=args.elite_frac,
174
+ image_kwargs={"num_inference_steps": 1, "guidance_scale": 0.0},
175
+ video_kwargs=_vkw(args),
176
+ score_kwargs={},
177
+ video_size=args.score_size,
178
+ num_frames=args.score_frames,
179
+ )
180
+
181
+ class FixedImageT2I:
182
+ def generate(self, prompts: list[str], **kwargs) -> list[Any]:
183
+ return [best_image for _ in prompts]
184
+
185
+ i2v = SeededImageToVideo(i2v_base, seed)
186
+ video_result, _image, best_video = best_artifacts_from_search(
187
+ video_search,
188
+ video_space,
189
+ FixedImageT2I(),
190
+ i2v,
191
+ scorer,
192
+ target,
193
+ seed,
194
+ {},
195
+ _vkw(args),
196
+ {},
197
+ )
198
+ best_video_path = seed_dir / "best_video.mp4"
199
+ save_any_video(best_video, best_video_path, fps=args.fps)
200
+ np.save(seed_dir / "video_history_best.npy", np.asarray(video_result.history_best, dtype=np.float32))
201
+
202
+ meta = {
203
+ "roi": roi,
204
+ "seed": seed,
205
+ "num_voxels": len(indices),
206
+ "image": {
207
+ "max_evals": args.image_evals,
208
+ "best_prompt": image_result.best_prompt,
209
+ "best_score": image_result.best_score,
210
+ "best_image": str(best_image_path),
211
+ },
212
+ "video": {
213
+ "max_evals": args.video_evals,
214
+ "best_prompt": video_result.best_prompt,
215
+ "best_score": video_result.best_score,
216
+ "best_video": str(best_video_path),
217
+ },
218
+ }
219
+ done.write_text(json.dumps(meta, indent=2))
220
+ print(f"[video done] {roi} seed={seed} score={video_result.best_score:.6f}", flush=True)
221
+ return meta
222
+
223
+
224
+ def main() -> None:
225
+ p = argparse.ArgumentParser()
226
+ p.add_argument("--rois", nargs="+", default=["FFA", "PPA", "pSTS", "MT"])
227
+ p.add_argument("--seeds", nargs="+", type=int, default=[33, 34, 35])
228
+ p.add_argument("--image-evals", type=int, default=StimulusSynthesisConfig().default_image_max_evals)
229
+ p.add_argument("--video-evals", type=int, default=StimulusSynthesisConfig().default_video_max_evals)
230
+ p.add_argument("--image-population", type=int, default=20)
231
+ p.add_argument("--video-population", type=int, default=20)
232
+ p.add_argument("--encoder-model", default=StimulusSynthesisConfig().encoder_model_id)
233
+ p.add_argument("--mutation-rate", type=float, default=0.2)
234
+ p.add_argument("--crossover-rate", type=float, default=0.5)
235
+ p.add_argument("--elite-frac", type=float, default=0.3)
236
+ p.add_argument("--out-dir", default=str(get_cache_dir() / "results" / "hf_nevo_roi_samples"))
237
+ p.add_argument("--device", default="cuda")
238
+ p.add_argument("--score-size", type=int, default=224)
239
+ p.add_argument("--score-frames", type=int, default=16)
240
+ p.add_argument("--video-width", type=int, default=256)
241
+ p.add_argument("--video-height", type=int, default=256)
242
+ p.add_argument("--video-frames", type=int, default=49)
243
+ p.add_argument("--video-steps", type=int, default=0, help="LTX num_inference_steps; 0 = model default")
244
+ p.add_argument("--fps", type=int, default=24)
245
+ p.add_argument("--overwrite", action="store_true")
246
+ args = p.parse_args()
247
+
248
+ device = args.device if torch.cuda.is_available() else "cpu"
249
+ Path(args.out_dir).mkdir(parents=True, exist_ok=True)
250
+ print(f"device={device} out_dir={args.out_dir}", flush=True)
251
+
252
+ t0 = time.time()
253
+ t2i_base = DiffusersTextToImageAdapter("stabilityai/sdxl-turbo", device=device)
254
+ i2v_base = DiffusersImageToVideoAdapter("Lightricks/LTX-Video-0.9.8-13B-distilled", device=device)
255
+ scorer = EncoderScorer(
256
+ args.encoder_model,
257
+ encoder_call="predict_fmri",
258
+ objective="indices_mean",
259
+ device=device,
260
+ )
261
+ print(f"components loaded in {time.time() - t0:.1f}s", flush=True)
262
+
263
+ all_meta = []
264
+ for roi in args.rois:
265
+ for seed in args.seeds:
266
+ all_meta.append(run_one(roi, seed, args, t2i_base, i2v_base, scorer))
267
+ summary_path = Path(args.out_dir) / "summary.json"
268
+ summary_path.write_text(json.dumps(all_meta, indent=2))
269
+ print(f"[done] wrote {summary_path}", flush=True)
270
+
271
+
272
+ if __name__ == "__main__":
273
+ main()
stimulus_synthesis/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from .paths import configure_cache as _configure_cache
2
+ _configure_cache()
3
+
4
+ from .pipeline import NevoPipeline
5
+ from .outputs import StimulusSynthesisOutput
6
+ from .neuro import resolve_driving_voxels
7
+
8
+ __all__ = ["NevoPipeline", "StimulusSynthesisOutput", "resolve_driving_voxels"]
stimulus_synthesis/asset_manifest.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, is_dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ def write_asset_manifest(records: list[Any], path: str | Path, *, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
10
+ manifest = {
11
+ "metadata": metadata or {},
12
+ "records": [_to_jsonable(record) for record in records],
13
+ }
14
+ path = Path(path)
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+ path.write_text(json.dumps(manifest, indent=2, sort_keys=True))
17
+ return manifest
18
+
19
+
20
+ def load_asset_manifest(path: str | Path) -> dict[str, Any]:
21
+ return json.loads(Path(path).read_text())
22
+
23
+
24
+ def _to_jsonable(value: Any) -> Any:
25
+ if hasattr(value, "to_dict"):
26
+ return _to_jsonable(value.to_dict())
27
+ if is_dataclass(value):
28
+ return _to_jsonable(asdict(value))
29
+ if isinstance(value, dict):
30
+ return {str(k): _to_jsonable(v) for k, v in value.items()}
31
+ if isinstance(value, (list, tuple)):
32
+ return [_to_jsonable(v) for v in value]
33
+ if isinstance(value, Path):
34
+ return str(value)
35
+ return value
stimulus_synthesis/config.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class StimulusSynthesisConfig:
11
+ encoder_model_id: str = "epfl-neuroai/vjepa2-encoder-basic"
12
+ encoder_call: str = "predict_fmri"
13
+ text_to_image_model_id: str = "stabilityai/sdxl-turbo"
14
+ image_to_video_model_id: str = "Lightricks/LTX-Video-0.9.8-13B-distilled"
15
+ default_objective: str = "indices_mean"
16
+ default_device: str = "cuda"
17
+ default_population_size: int = 20
18
+ default_image_max_evals: int = 400
19
+ default_video_max_evals: int = 200
20
+ default_score_frames: int = 24
21
+ default_score_size: int = 224
22
+ default_image_batch_size: int = 16
23
+ default_video_batch_size: int = 8
24
+ default_mutation_rate: float = 0.25
25
+ default_elite_frac: float = 0.35
26
+ default_score_transform: dict[str, Any] | None = field(
27
+ default_factory=lambda: {
28
+ "enabled": False,
29
+ "crop_scale": 0.80,
30
+ "gaussian_sigma": 0.10,
31
+ "num_draws": 4,
32
+ "aggregate": "mean",
33
+ "seed": 0,
34
+ }
35
+ )
36
+ default_image_kwargs: dict[str, Any] = field(
37
+ default_factory=lambda: {"num_inference_steps": 1, "guidance_scale": 0.0, "height": 512, "width": 512}
38
+ )
39
+ default_video_kwargs: dict[str, Any] = field(
40
+ default_factory=lambda: {"num_inference_steps": 8, "num_frames": 25, "height": 512, "width": 512}
41
+ )
42
+
43
+ @classmethod
44
+ def from_json_file(cls, path: str | Path) -> "StimulusSynthesisConfig":
45
+ with open(path, "r") as f:
46
+ data = json.load(f)
47
+ return cls(**data)
48
+
49
+ @classmethod
50
+ def from_dict(cls, data: dict[str, Any]) -> "StimulusSynthesisConfig":
51
+ known = {field.name for field in cls.__dataclass_fields__.values()}
52
+ return cls(**{k: v for k, v in data.items() if k in known})
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ return {
56
+ "encoder_model_id": self.encoder_model_id,
57
+ "encoder_call": self.encoder_call,
58
+ "text_to_image_model_id": self.text_to_image_model_id,
59
+ "image_to_video_model_id": self.image_to_video_model_id,
60
+ "default_objective": self.default_objective,
61
+ "default_device": self.default_device,
62
+ "default_population_size": self.default_population_size,
63
+ "default_image_max_evals": self.default_image_max_evals,
64
+ "default_video_max_evals": self.default_video_max_evals,
65
+ "default_score_frames": self.default_score_frames,
66
+ "default_score_size": self.default_score_size,
67
+ "default_image_batch_size": self.default_image_batch_size,
68
+ "default_video_batch_size": self.default_video_batch_size,
69
+ "default_mutation_rate": self.default_mutation_rate,
70
+ "default_elite_frac": self.default_elite_frac,
71
+ "default_score_transform": self.default_score_transform,
72
+ "default_image_kwargs": self.default_image_kwargs,
73
+ "default_video_kwargs": self.default_video_kwargs,
74
+ }
stimulus_synthesis/data/__init__.py ADDED
File without changes
stimulus_synthesis/data/roi_masks.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3be584b579472777c7f41e3589e47b6cee5b608636c633bf605e1dd266f76914
3
+ size 5758
stimulus_synthesis/data/searchlight_both.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2c3e3fec1a88d56e182ccb1362f303f50fa24bae1d8796fee99f80b30e2bea76
3
+ size 2585
stimulus_synthesis/data/searchlight_lh.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a9ba6bc47b7d7075c0faf0cf6edd8acebaf9f2fe1f588899d6e7323b00f89979
3
+ size 1379
stimulus_synthesis/data/searchlight_rh.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:838c8ef226320a484c1776092d653d73d77721881b2c64113a88d13b83190b3a
3
+ size 1478
stimulus_synthesis/generators/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from .base import ImageToVideoGenerator, TextToImageGenerator
2
+ from .diffusers_i2v import DiffusersImageToVideoAdapter
3
+ from .diffusers_t2i import DiffusersTextToImageAdapter
4
+
5
+ __all__ = ["TextToImageGenerator", "ImageToVideoGenerator", "DiffusersTextToImageAdapter", "DiffusersImageToVideoAdapter"]
stimulus_synthesis/generators/base.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+
7
+ class TextToImageGenerator(ABC):
8
+ @abstractmethod
9
+ def generate(self, prompts: list[str], *, generator: Any | None = None, **kwargs) -> list[Any]:
10
+ ...
11
+
12
+
13
+ class ImageToVideoGenerator(ABC):
14
+ @abstractmethod
15
+ def generate(self, image: Any, prompt: str, *, generator: Any | None = None, **kwargs) -> Any:
16
+ ...
17
+
18
+ def generate_batch(self, images: list[Any], prompts: list[str], *, generators: list[Any] | None = None, **kwargs) -> list[Any]:
19
+ generators = generators or [None] * len(images)
20
+ return [
21
+ self.generate(image, prompt, generator=gen, **kwargs)
22
+ for image, prompt, gen in zip(images, prompts, generators)
23
+ ]
stimulus_synthesis/generators/diffusers_i2v.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import torch
6
+ from diffusers import DiffusionPipeline
7
+
8
+ from .base import ImageToVideoGenerator
9
+
10
+
11
+ class DiffusersImageToVideoAdapter(ImageToVideoGenerator):
12
+ def __init__(self, model_id: str, device: str = "cuda", torch_dtype: Any | None = None, pipeline: Any | None = None, **kwargs) -> None:
13
+ if pipeline is None:
14
+ dtype = torch_dtype
15
+ if dtype is None and str(device).startswith("cuda"):
16
+ dtype = torch.bfloat16
17
+ pipeline_cls = kwargs.pop("pipeline_cls", None) or _default_i2v_pipeline_cls(model_id)
18
+ pipeline = pipeline_cls.from_pretrained(model_id, torch_dtype=dtype, **kwargs)
19
+ self.pipe = pipeline
20
+ self.device = device
21
+ if hasattr(self.pipe, "to"):
22
+ self.pipe.to(device)
23
+ if hasattr(self.pipe, "set_progress_bar_config"):
24
+ self.pipe.set_progress_bar_config(disable=True)
25
+
26
+ def generate(self, image: Any, prompt: str, *, generator: Any | None = None, **kwargs) -> Any:
27
+ kwargs.setdefault("output_type", "pt")
28
+ try:
29
+ out = self.pipe(image=image, prompt=prompt, generator=generator, **kwargs)
30
+ except TypeError as exc:
31
+ raise TypeError(
32
+ "The configured image-to-video model does not support the default "
33
+ "`image=..., prompt=..., generator=..., **kwargs` signature. "
34
+ "Pass a custom ImageToVideoGenerator adapter."
35
+ ) from exc
36
+ return self._normalize_output(out)
37
+
38
+ def generate_batch(self, images, prompts, *, generators=None, **kwargs):
39
+ images = list(images)
40
+ prompts = list(prompts)
41
+ if not prompts:
42
+ return []
43
+ kwargs.setdefault("output_type", "pt")
44
+ try:
45
+ out = self.pipe(image=images, prompt=prompts, generator=generators, **kwargs)
46
+ frames = getattr(out, "frames", None)
47
+ if frames is None:
48
+ frames = getattr(out, "videos", None)
49
+ if torch.is_tensor(frames) and frames.ndim == 5 and frames.shape[0] == len(prompts):
50
+ return [frames[i] for i in range(len(prompts))]
51
+ if isinstance(frames, (list, tuple)) and len(frames) == len(prompts):
52
+ return list(frames)
53
+ except (RuntimeError, TypeError, ValueError):
54
+ pass
55
+ # Fall back to per-item generation (e.g. model can't batch, or OOM).
56
+ gens = generators if isinstance(generators, (list, tuple)) else [generators] * len(prompts)
57
+ return [self.generate(img, p, generator=g, **kwargs) for img, p, g in zip(images, prompts, gens)]
58
+
59
+ @staticmethod
60
+ def _normalize_output(out: Any) -> Any:
61
+ frames = getattr(out, "frames", None)
62
+ if frames is None:
63
+ frames = getattr(out, "videos", None)
64
+ if frames is None:
65
+ return out
66
+ if torch.is_tensor(frames):
67
+ return frames[0] if frames.ndim == 5 else frames
68
+ if isinstance(frames, (list, tuple)) and frames:
69
+ return frames[0]
70
+ return frames
71
+
72
+
73
+ def _default_i2v_pipeline_cls(model_id: str):
74
+ if model_id == "Lightricks/LTX-Video":
75
+ from diffusers import LTXImageToVideoPipeline
76
+
77
+ return LTXImageToVideoPipeline
78
+ return DiffusionPipeline
stimulus_synthesis/generators/diffusers_t2i.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import torch
6
+ from diffusers import DiffusionPipeline
7
+
8
+ from .base import TextToImageGenerator
9
+
10
+
11
+ class DiffusersTextToImageAdapter(TextToImageGenerator):
12
+ def __init__(self, model_id: str, device: str = "cuda", torch_dtype: Any | None = None, pipeline: Any | None = None, **kwargs) -> None:
13
+ if pipeline is None:
14
+ dtype = torch_dtype
15
+ if dtype is None and str(device).startswith("cuda"):
16
+ dtype = torch.float16
17
+ pipeline = DiffusionPipeline.from_pretrained(model_id, torch_dtype=dtype, **kwargs)
18
+ self.pipe = pipeline
19
+ self.device = device
20
+ if hasattr(self.pipe, "to"):
21
+ self.pipe.to(device)
22
+ if hasattr(self.pipe, "set_progress_bar_config"):
23
+ self.pipe.set_progress_bar_config(disable=True)
24
+
25
+ def generate(self, prompts: list[str], *, generator: Any | None = None, **kwargs) -> list[Any]:
26
+ out = self.pipe(prompt=prompts, generator=generator, **kwargs)
27
+ if hasattr(out, "images"):
28
+ return list(out.images)
29
+ if isinstance(out, list):
30
+ return out
31
+ raise TypeError("Text-to-image pipeline output does not expose `.images`.")
32
+
33
+ def generate_batch(self, prompts, *, generators=None, **kwargs):
34
+ out = self.pipe(prompt=list(prompts), generator=generators, **kwargs)
35
+ if hasattr(out, "images"):
36
+ return list(out.images)
37
+ if isinstance(out, list):
38
+ return out
39
+ raise TypeError("Text-to-image pipeline output does not expose `.images`.")
stimulus_synthesis/media/__init__.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .asset_decode import DecodedImage, DecodedVideo, decode_image, decode_video
2
+ from .asset_export import AssetExportRecord, export_image, export_video, sha256_file
3
+ from .asset_spec import ImageAssetSpec, VideoAssetSpec
4
+ from .normalize import video_to_t_c_h_w, videos_to_b_t_c_h_w
5
+ from .video_io import load_video_as_tensor, save_video
6
+
7
+ __all__ = [
8
+ "AssetExportRecord",
9
+ "DecodedImage",
10
+ "DecodedVideo",
11
+ "ImageAssetSpec",
12
+ "VideoAssetSpec",
13
+ "decode_image",
14
+ "decode_video",
15
+ "export_image",
16
+ "export_video",
17
+ "load_video_as_tensor",
18
+ "save_video",
19
+ "sha256_file",
20
+ "video_to_t_c_h_w",
21
+ "videos_to_b_t_c_h_w",
22
+ ]
stimulus_synthesis/media/asset_decode.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from fractions import Fraction
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import av
9
+ import numpy as np
10
+ import torch
11
+ from PIL import Image
12
+
13
+ from .asset_export import sha256_file
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class DecodedImage:
18
+ path: str
19
+ sha256: str
20
+ image: torch.Tensor
21
+ width: int
22
+ height: int
23
+
24
+ def metadata(self) -> dict[str, Any]:
25
+ data = asdict(self)
26
+ data.pop("image")
27
+ return data
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DecodedVideo:
32
+ path: str
33
+ sha256: str
34
+ frames: torch.Tensor
35
+ width: int
36
+ height: int
37
+ num_frames: int
38
+ fps: float | None
39
+
40
+ def metadata(self) -> dict[str, Any]:
41
+ data = asdict(self)
42
+ data.pop("frames")
43
+ return data
44
+
45
+
46
+ def decode_image(path: str | Path) -> DecodedImage:
47
+ path = Path(path)
48
+ image = Image.open(path).convert("RGB")
49
+ tensor = torch.from_numpy(np.asarray(image).copy()).float().permute(2, 0, 1) / 255.0
50
+ return DecodedImage(
51
+ path=str(path),
52
+ sha256=sha256_file(path),
53
+ image=tensor.contiguous(),
54
+ width=image.width,
55
+ height=image.height,
56
+ )
57
+
58
+
59
+ def decode_video(path: str | Path) -> DecodedVideo:
60
+ path = Path(path)
61
+ container = av.open(str(path))
62
+ try:
63
+ stream = container.streams.video[0]
64
+ fps = _fraction_to_float(stream.average_rate or stream.base_rate)
65
+ frames = [
66
+ torch.from_numpy(frame.to_ndarray(format="rgb24")).float().permute(2, 0, 1) / 255.0
67
+ for frame in container.decode(video=0)
68
+ ]
69
+ finally:
70
+ container.close()
71
+ if not frames:
72
+ raise ValueError(f"Decoded video has no frames: {path}")
73
+ tensor = torch.stack(frames, dim=0).contiguous()
74
+ return DecodedVideo(
75
+ path=str(path),
76
+ sha256=sha256_file(path),
77
+ frames=tensor,
78
+ width=int(tensor.shape[-1]),
79
+ height=int(tensor.shape[-2]),
80
+ num_frames=int(tensor.shape[0]),
81
+ fps=fps,
82
+ )
83
+
84
+
85
+ def _fraction_to_float(value: Fraction | None) -> float | None:
86
+ if value is None:
87
+ return None
88
+ return float(value)
stimulus_synthesis/media/asset_export.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import asdict, dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import av
9
+ import numpy as np
10
+ import torch
11
+ import torch.nn.functional as F
12
+ from PIL import Image
13
+
14
+ from .asset_spec import ImageAssetSpec, VideoAssetSpec
15
+ from .normalize import video_to_t_c_h_w
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class AssetExportRecord:
20
+ path: str
21
+ asset_type: str
22
+ sha256: str
23
+ bytes: int
24
+ spec: dict[str, Any]
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ return asdict(self)
28
+
29
+
30
+ def sha256_file(path: str | Path) -> str:
31
+ h = hashlib.sha256()
32
+ with open(path, "rb") as f:
33
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
34
+ h.update(chunk)
35
+ return h.hexdigest()
36
+
37
+
38
+ def export_image(image: Any, path: str | Path, spec: ImageAssetSpec) -> AssetExportRecord:
39
+ path = Path(path)
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ pil = _to_pil_rgb(image)
42
+ pil = _resize_pil(pil, (spec.width, spec.height), spec.resize_filter)
43
+ save_kwargs: dict[str, Any] = {}
44
+ fmt = spec.format.upper()
45
+ if spec.quality is not None and fmt in {"JPEG", "JPG", "WEBP"}:
46
+ save_kwargs["quality"] = int(spec.quality)
47
+ if fmt == "JPG":
48
+ fmt = "JPEG"
49
+ pil.save(path, format=fmt, **save_kwargs)
50
+ return AssetExportRecord(
51
+ path=str(path),
52
+ asset_type="image",
53
+ sha256=sha256_file(path),
54
+ bytes=path.stat().st_size,
55
+ spec=spec.to_dict(),
56
+ )
57
+
58
+
59
+ def export_video(frames: Any, path: str | Path, spec: VideoAssetSpec) -> AssetExportRecord:
60
+ path = Path(path)
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ video = video_to_t_c_h_w(frames).clamp(0.0, 1.0)
63
+ video = _match_frames(video, spec.num_frames)
64
+ video = _resize_video(video, (spec.height, spec.width), spec.resize_filter)
65
+ frames_np = (video.detach().cpu().permute(0, 2, 3, 1).numpy() * 255.0).round().clip(0, 255).astype(np.uint8)
66
+
67
+ container = av.open(str(path), mode="w", format=spec.container)
68
+ try:
69
+ stream = container.add_stream(spec.codec, rate=spec.fps)
70
+ stream.width = spec.width
71
+ stream.height = spec.height
72
+ stream.pix_fmt = spec.pixel_format
73
+ options: dict[str, str] = {}
74
+ if spec.crf is not None:
75
+ options["crf"] = str(int(spec.crf))
76
+ if spec.preset is not None:
77
+ options["preset"] = str(spec.preset)
78
+ stream.options = options
79
+ for frame_np in frames_np:
80
+ container.mux(stream.encode(av.VideoFrame.from_ndarray(frame_np, format="rgb24")))
81
+ container.mux(stream.encode(None))
82
+ finally:
83
+ container.close()
84
+
85
+ return AssetExportRecord(
86
+ path=str(path),
87
+ asset_type="video",
88
+ sha256=sha256_file(path),
89
+ bytes=path.stat().st_size,
90
+ spec=spec.to_dict(),
91
+ )
92
+
93
+
94
+ def _to_pil_rgb(image: Any) -> Image.Image:
95
+ if isinstance(image, Image.Image):
96
+ return image.convert("RGB")
97
+ if torch.is_tensor(image):
98
+ tensor = image.detach().cpu().float()
99
+ if tensor.ndim == 4 and tensor.shape[0] == 1:
100
+ tensor = tensor.squeeze(0)
101
+ if tensor.ndim != 3:
102
+ raise ValueError(f"Expected image tensor with 3 dims, got {tuple(tensor.shape)}")
103
+ if tensor.shape[0] == 3:
104
+ tensor = tensor.permute(1, 2, 0)
105
+ if tensor.max() <= 1.0:
106
+ tensor = tensor * 255.0
107
+ arr = tensor.round().clamp(0, 255).byte().numpy()
108
+ return Image.fromarray(arr, mode="RGB")
109
+ if isinstance(image, np.ndarray):
110
+ arr = image
111
+ if arr.ndim != 3:
112
+ raise ValueError(f"Expected image array with 3 dims, got {arr.shape}")
113
+ if arr.shape[0] == 3 and arr.shape[-1] != 3:
114
+ arr = np.transpose(arr, (1, 2, 0))
115
+ if arr.dtype != np.uint8:
116
+ arr = arr.astype(np.float32)
117
+ if arr.max() <= 1.0:
118
+ arr = arr * 255.0
119
+ arr = np.rint(arr).clip(0, 255).astype(np.uint8)
120
+ return Image.fromarray(arr, mode="RGB")
121
+ raise TypeError(f"Unsupported image type: {type(image)!r}")
122
+
123
+
124
+ def _resize_pil(image: Image.Image, size: tuple[int, int], resize_filter: str) -> Image.Image:
125
+ if image.size == size:
126
+ return image
127
+ filters = {
128
+ "nearest": Image.Resampling.NEAREST,
129
+ "bilinear": Image.Resampling.BILINEAR,
130
+ "bicubic": Image.Resampling.BICUBIC,
131
+ "lanczos": Image.Resampling.LANCZOS,
132
+ }
133
+ return image.resize(size, filters.get(resize_filter.lower(), Image.Resampling.BICUBIC))
134
+
135
+
136
+ def _match_frames(video: torch.Tensor, num_frames: int) -> torch.Tensor:
137
+ if video.shape[0] == num_frames:
138
+ return video
139
+ if video.shape[0] > num_frames:
140
+ idx = torch.linspace(0, video.shape[0] - 1, steps=num_frames).round().long()
141
+ return video[idx]
142
+ reps = int(np.ceil(num_frames / video.shape[0]))
143
+ return video.repeat((reps, 1, 1, 1))[:num_frames]
144
+
145
+
146
+ def _resize_video(video: torch.Tensor, size_hw: tuple[int, int], resize_filter: str) -> torch.Tensor:
147
+ if tuple(video.shape[-2:]) == size_hw:
148
+ return video.contiguous()
149
+ mode = resize_filter.lower()
150
+ if mode not in {"nearest", "bilinear", "bicubic"}:
151
+ mode = "bilinear"
152
+ kwargs = {} if mode == "nearest" else {"align_corners": False}
153
+ return F.interpolate(video, size=size_hw, mode=mode, **kwargs).contiguous()
stimulus_synthesis/media/asset_spec.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class ImageAssetSpec:
9
+ width: int
10
+ height: int
11
+ format: str = "png"
12
+ quality: int | None = None
13
+ color_space: str = "srgb"
14
+ resize_filter: str = "bicubic"
15
+ resize_policy: str = "resize"
16
+
17
+ def __post_init__(self) -> None:
18
+ _validate_positive_int("width", self.width)
19
+ _validate_positive_int("height", self.height)
20
+ if self.quality is not None and not (1 <= int(self.quality) <= 100):
21
+ raise ValueError("quality must be between 1 and 100 when set.")
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ return asdict(self)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class VideoAssetSpec:
29
+ width: int
30
+ height: int
31
+ fps: int
32
+ num_frames: int
33
+ container: str = "mp4"
34
+ codec: str = "libx264"
35
+ crf: int | None = 10
36
+ preset: str | None = "slow"
37
+ pixel_format: str = "yuv420p"
38
+ color_space: str = "srgb"
39
+ resize_filter: str = "bilinear"
40
+ resize_policy: str = "resize"
41
+
42
+ def __post_init__(self) -> None:
43
+ _validate_positive_int("width", self.width)
44
+ _validate_positive_int("height", self.height)
45
+ _validate_positive_int("fps", self.fps)
46
+ _validate_positive_int("num_frames", self.num_frames)
47
+ if self.crf is not None and not (0 <= int(self.crf) <= 51):
48
+ raise ValueError("crf must be between 0 and 51 when set.")
49
+ if self.pixel_format == "yuv420p" and (self.width % 2 or self.height % 2):
50
+ raise ValueError("yuv420p video export requires even width and height.")
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ return asdict(self)
54
+
55
+
56
+ def _validate_positive_int(name: str, value: int) -> None:
57
+ if int(value) <= 0:
58
+ raise ValueError(f"{name} must be a positive integer.")
stimulus_synthesis/media/normalize.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import numpy as np
6
+ import torch
7
+ import torch.nn.functional as F
8
+ from PIL import Image
9
+
10
+
11
+ def _frame_to_tensor(frame: Any) -> torch.Tensor:
12
+ if isinstance(frame, Image.Image):
13
+ arr = np.asarray(frame.convert("RGB"), dtype=np.float32) / 255.0
14
+ return torch.from_numpy(arr).permute(2, 0, 1)
15
+ if isinstance(frame, np.ndarray):
16
+ arr = frame.astype(np.float32, copy=False)
17
+ if arr.max() > 1.0:
18
+ arr = arr / 255.0
19
+ tensor = torch.from_numpy(arr)
20
+ if tensor.ndim == 3 and tensor.shape[-1] == 3:
21
+ tensor = tensor.permute(2, 0, 1)
22
+ if tensor.ndim != 3:
23
+ raise ValueError(f"Expected frame array with 3 dims, got {arr.shape}")
24
+ return tensor.float().contiguous()
25
+ if torch.is_tensor(frame):
26
+ tensor = frame.detach().float()
27
+ if tensor.ndim == 3 and tensor.shape[-1] == 3:
28
+ tensor = tensor.permute(2, 0, 1)
29
+ if tensor.ndim != 3:
30
+ raise ValueError(f"Expected frame tensor with 3 dims, got {tuple(tensor.shape)}")
31
+ if tensor.max() > 1.0:
32
+ tensor = tensor / 255.0
33
+ return tensor.contiguous()
34
+ raise TypeError(f"Unsupported frame type: {type(frame)!r}")
35
+
36
+
37
+ def video_to_t_c_h_w(video: Any) -> torch.Tensor:
38
+ if torch.is_tensor(video):
39
+ tensor = video.detach().float()
40
+ if tensor.ndim == 5 and tensor.shape[0] == 1:
41
+ tensor = tensor.squeeze(0)
42
+ if tensor.ndim == 3:
43
+ tensor = tensor.unsqueeze(0)
44
+ if tensor.ndim != 4:
45
+ raise ValueError(f"Expected video tensor with 4 dims, got {tuple(tensor.shape)}")
46
+ if tensor.shape[-1] == 3:
47
+ tensor = tensor.permute(0, 3, 1, 2)
48
+ if tensor.max() > 1.0:
49
+ tensor = tensor / 255.0
50
+ return tensor.contiguous()
51
+
52
+ if isinstance(video, np.ndarray):
53
+ arr = video.astype(np.float32, copy=False)
54
+ if arr.ndim == 5 and arr.shape[0] == 1:
55
+ arr = arr[0]
56
+ if arr.ndim == 3:
57
+ return _frame_to_tensor(arr).unsqueeze(0).contiguous()
58
+ if arr.ndim != 4:
59
+ raise ValueError(f"Expected video array with 4 dims, got {arr.shape}")
60
+ tensor = torch.from_numpy(arr)
61
+ if tensor.shape[-1] == 3:
62
+ tensor = tensor.permute(0, 3, 1, 2)
63
+ if tensor.max() > 1.0:
64
+ tensor = tensor.float() / 255.0
65
+ return tensor.float().contiguous()
66
+
67
+ if isinstance(video, (list, tuple)):
68
+ if not video:
69
+ raise ValueError("Video frame list is empty.")
70
+ return torch.stack([_frame_to_tensor(frame) for frame in video], dim=0).contiguous()
71
+
72
+ if isinstance(video, Image.Image):
73
+ return _frame_to_tensor(video).unsqueeze(0).contiguous()
74
+
75
+ raise TypeError(f"Unsupported video type: {type(video)!r}")
76
+
77
+
78
+ def videos_to_b_t_c_h_w(videos: list[Any], *, size: int | tuple[int, int] | None = None, num_frames: int | None = None) -> torch.Tensor:
79
+ tensors = [video_to_t_c_h_w(video) for video in videos]
80
+ if num_frames is not None:
81
+ tensors = [_match_frames(tensor, num_frames) for tensor in tensors]
82
+ if size is not None:
83
+ target_size = (size, size) if isinstance(size, int) else tuple(size)
84
+ tensors = [_resize_video(tensor, target_size) for tensor in tensors]
85
+ return torch.stack(tensors, dim=0).clamp(0.0, 1.0).contiguous()
86
+
87
+
88
+ def _match_frames(video: torch.Tensor, num_frames: int) -> torch.Tensor:
89
+ if video.shape[0] == num_frames:
90
+ return video
91
+ if video.shape[0] > num_frames:
92
+ idx = torch.linspace(0, video.shape[0] - 1, steps=num_frames).round().long()
93
+ return video[idx]
94
+ reps = int(np.ceil(num_frames / video.shape[0]))
95
+ return video.repeat((reps, 1, 1, 1))[:num_frames]
96
+
97
+
98
+ def _resize_video(video: torch.Tensor, size: tuple[int, int]) -> torch.Tensor:
99
+ return F.interpolate(video, size=size, mode="bilinear", align_corners=False)
stimulus_synthesis/media/video_io.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import av
4
+ import numpy as np
5
+ import torch
6
+
7
+
8
+ def load_video_as_tensor(mp4_path: str) -> torch.Tensor:
9
+ container = av.open(mp4_path)
10
+ try:
11
+ frames = [
12
+ torch.from_numpy(frame.to_ndarray(format="rgb24")).float() / 255.0
13
+ for frame in container.decode(video=0)
14
+ ]
15
+ finally:
16
+ container.close()
17
+ return torch.stack(frames).permute(0, 3, 1, 2).contiguous()
18
+
19
+
20
+ def save_video(tensor: torch.Tensor, path: str, fps: int = 24) -> None:
21
+ frames_np = (tensor.detach().cpu().permute(0, 2, 3, 1).numpy() * 255).clip(0, 255).astype(np.uint8)
22
+ height, width = frames_np.shape[1], frames_np.shape[2]
23
+ height, width = height - (height % 2), width - (width % 2)
24
+ frames_np = frames_np[:, :height, :width, :]
25
+
26
+ container = av.open(path, mode="w")
27
+ stream = container.add_stream("libx264", rate=fps)
28
+ stream.width, stream.height = width, height
29
+ stream.pix_fmt = "yuv420p"
30
+ stream.options = {"crf": "10", "preset": "slow"}
31
+ for frame_np in frames_np:
32
+ container.mux(stream.encode(av.VideoFrame.from_ndarray(frame_np, format="rgb24")))
33
+ container.mux(stream.encode(None))
34
+ container.close()
stimulus_synthesis/neuro/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .roi import resolve_driving_voxels, available_rois, searchlight_counts, N_TOTAL_VOXELS
2
+
3
+ __all__ = ["resolve_driving_voxels", "available_rois", "searchlight_counts", "N_TOTAL_VOXELS"]
stimulus_synthesis/neuro/roi.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-contained ROI -> voxel-mask resolver.
2
+
3
+ Removes the need for an external ROI-atlas package (nilearn + fsaverage atlas
4
+ data). The ROI and
5
+ searchlight masks are precomputed and shipped as data under ``stimulus_synthesis/data``,
6
+ so resolution here needs only numpy.
7
+
8
+ Token grammar (identical to the original):
9
+ - ``ROI`` -> both hemispheres (e.g. ``FFA``)
10
+ - ``ROI_lh`` / ``ROI_rh``
11
+ - ``SL-<n>`` -> both-hemisphere searchlight region (1-indexed)
12
+ - ``SL-<n>_lh`` / ``SL-<n>_rh``
13
+ - comma-separated tokens are unioned.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+
22
+ N_TOTAL_VOXELS = 20484
23
+ N_HEMI_VOXELS = N_TOTAL_VOXELS // 2
24
+
25
+ _DATA_DIR = Path(__file__).parent.parent / "data"
26
+ _ROI_HEMI_PATTERN = re.compile(r"^(?P<name>.+?)_(?P<hemi>lh|rh)$", flags=re.IGNORECASE)
27
+ _SL_PATTERN = re.compile(r"^SL-(?P<idx>\d+)(?:_(?P<hemi>lh|rh))?$", flags=re.IGNORECASE)
28
+
29
+ _named_cache: dict[str, np.ndarray] | None = None
30
+ _sl_cache: dict[str, np.ndarray] = {}
31
+
32
+
33
+ def _load_named() -> dict[str, np.ndarray]:
34
+ global _named_cache
35
+ if _named_cache is None:
36
+ with np.load(_DATA_DIR / "roi_masks.npz") as z:
37
+ _named_cache = {k: z[k].astype(bool) for k in z.files}
38
+ return _named_cache
39
+
40
+
41
+ def _load_searchlight(key: str) -> np.ndarray:
42
+ if key not in _sl_cache:
43
+ with np.load(_DATA_DIR / f"searchlight_{key}.npz") as z:
44
+ _sl_cache[key] = z["regions"].astype(bool)
45
+ return _sl_cache[key]
46
+
47
+
48
+ def _hemi_slice(hemi: str) -> slice:
49
+ hemi = hemi.lower()
50
+ if hemi == "lh":
51
+ return slice(0, N_HEMI_VOXELS)
52
+ if hemi == "rh":
53
+ return slice(N_HEMI_VOXELS, N_TOTAL_VOXELS)
54
+ raise ValueError(f"Invalid hemisphere '{hemi}'. Expected 'lh' or 'rh'.")
55
+
56
+
57
+ def _restrict_to_hemi(mask: np.ndarray, hemi: str | None) -> np.ndarray:
58
+ if hemi is None:
59
+ return mask
60
+ restricted = np.zeros_like(mask)
61
+ restricted[_hemi_slice(hemi)] = mask[_hemi_slice(hemi)]
62
+ return restricted
63
+
64
+
65
+ def available_rois() -> list[str]:
66
+ """Named ROIs shipped with the package."""
67
+ return sorted(_load_named())
68
+
69
+
70
+ def searchlight_counts() -> dict[str, int]:
71
+ """Number of searchlight regions available per hemisphere key."""
72
+ return {k: len(_load_searchlight(k)) for k in ("both", "lh", "rh")}
73
+
74
+
75
+ def resolve_driving_voxels(target_roi: str) -> np.ndarray:
76
+ """Resolve ROI token(s) to a full-brain boolean mask of length 20484."""
77
+ rois = [x.strip() for x in str(target_roi).split(",") if x.strip()]
78
+ if not rois:
79
+ raise ValueError("target_roi must contain at least one ROI token.")
80
+
81
+ combined = np.zeros(N_TOTAL_VOXELS, dtype=bool)
82
+ for token in rois:
83
+ sl_match = _SL_PATTERN.match(token)
84
+ if sl_match is not None:
85
+ idx = int(sl_match.group("idx"))
86
+ hemi = sl_match.group("hemi")
87
+ key = "both" if hemi is None else hemi.lower()
88
+ regions = _load_searchlight(key)
89
+ if idx < 1 or idx > len(regions):
90
+ raise ValueError(f"Invalid {token}: n must be in [1, {len(regions)}].")
91
+ combined |= regions[idx - 1]
92
+ continue
93
+
94
+ hemi = None
95
+ name = token
96
+ roi_match = _ROI_HEMI_PATTERN.match(token)
97
+ if roi_match is not None:
98
+ name = roi_match.group("name")
99
+ hemi = roi_match.group("hemi").lower()
100
+
101
+ named = _load_named()
102
+ if name not in named:
103
+ lookup = {k.lower(): k for k in named}
104
+ if name.lower() in lookup:
105
+ name = lookup[name.lower()]
106
+ else:
107
+ raise KeyError(f"Unknown ROI '{name}'. Available: {sorted(named)}")
108
+ combined |= _restrict_to_hemi(named[name].copy(), hemi)
109
+
110
+ return combined
stimulus_synthesis/outputs.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class StimulusCandidate:
9
+ prompt: str
10
+ score: float
11
+ image: Any | None = None
12
+ video: Any | None = None
13
+ metadata: dict[str, Any] = field(default_factory=dict)
14
+
15
+
16
+ @dataclass
17
+ class StimulusSynthesisOutput:
18
+ candidates: list[StimulusCandidate]
19
+ best_prompt: str
20
+ best_score: float
21
+ history_best: list[float]
22
+ metadata: dict[str, Any] = field(default_factory=dict)
23
+
24
+ @property
25
+ def best(self) -> StimulusCandidate:
26
+ return self.candidates[0]
stimulus_synthesis/paths.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Repo-local cache/path configuration.
2
+
3
+ Cache directory resolution order:
4
+ 1. ``NEVO_CACHE_DIR`` environment variable (may be set via a ``.env`` file at the
5
+ repository root),
6
+ 2. otherwise ``<repo>/cache``.
7
+
8
+ ``configure_cache()`` points HuggingFace/torch caches at that directory (using
9
+ ``setdefault`` so any caller-provided env wins). No third-party dotenv dependency;
10
+ the ``.env`` parser here handles simple ``KEY=VALUE`` lines.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from pathlib import Path
16
+
17
+ _REPO_ROOT = Path(__file__).parent.parent
18
+
19
+
20
+ def _load_dotenv(root: Path) -> None:
21
+ env_path = root / ".env"
22
+ if not env_path.exists():
23
+ return
24
+ for line in env_path.read_text().splitlines():
25
+ line = line.strip()
26
+ if not line or line.startswith("#") or "=" not in line:
27
+ continue
28
+ key, value = line.split("=", 1)
29
+ key = key.strip()
30
+ value = value.strip().strip('"').strip("'")
31
+ if key:
32
+ os.environ.setdefault(key, value)
33
+
34
+
35
+ def get_cache_dir() -> Path:
36
+ """Resolve and create the cache directory."""
37
+ _load_dotenv(_REPO_ROOT)
38
+ override = os.environ.get("NEVO_CACHE_DIR")
39
+ path = Path(override).expanduser() if override else (_REPO_ROOT / "cache")
40
+ path.mkdir(parents=True, exist_ok=True)
41
+ return path
42
+
43
+
44
+ def _point_env_at(cache: Path) -> Path:
45
+ cache.mkdir(parents=True, exist_ok=True)
46
+ os.environ.setdefault("HF_HOME", str(cache / "huggingface"))
47
+ os.environ.setdefault("HUGGINGFACE_HUB_CACHE", str(cache / "huggingface" / "hub"))
48
+ os.environ.setdefault("TORCH_HOME", str(cache / "torch"))
49
+ return cache
50
+
51
+
52
+ def configure_cache() -> Path | None:
53
+ """Configure the HuggingFace/torch cache location.
54
+
55
+ Priority:
56
+ 1. ``NEVO_CACHE_DIR`` (from environment or ``.env``) — use it.
57
+ 2. Otherwise, respect the system/user-default HuggingFace cache
58
+ (``HF_HOME``/``HUGGINGFACE_HUB_CACHE`` if set, else ``~/.cache/huggingface``)
59
+ and leave the environment untouched.
60
+ 3. Only if no default is resolvable (no usable home directory) fall back to
61
+ ``<repo>/cache``.
62
+
63
+ Returns the cache dir when this function set one, or ``None`` when the system
64
+ default is left in place.
65
+ """
66
+ _load_dotenv(_REPO_ROOT)
67
+ override = os.environ.get("NEVO_CACHE_DIR")
68
+ if override:
69
+ return _point_env_at(Path(override).expanduser())
70
+ # Respect an already-configured / default HuggingFace cache.
71
+ if os.environ.get("HF_HOME") or os.environ.get("HUGGINGFACE_HUB_CACHE"):
72
+ return None
73
+ home = os.path.expanduser("~")
74
+ if home and home != "~" and os.path.isdir(home):
75
+ return None # HuggingFace will use its own default (~/.cache/huggingface)
76
+ # No usable default: fall back to a repo-local cache.
77
+ return _point_env_at(_REPO_ROOT / "cache")
stimulus_synthesis/pipeline.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import torch
9
+ from diffusers import DiffusionPipeline
10
+
11
+ from .config import StimulusSynthesisConfig
12
+ from .generators.diffusers_i2v import DiffusersImageToVideoAdapter
13
+ from .generators.diffusers_t2i import DiffusersTextToImageAdapter
14
+ from .outputs import StimulusCandidate, StimulusSynthesisOutput
15
+ from .scoring.encoder_scorer import EncoderScorer
16
+ from .scoring.robust_transform import RobustTransformScorer, RobustTransformSpec
17
+ from .spaces.structured_neuro_space import StructuredArtPromptSpace, VideoMotionPromptSpace, art_data, make_t2v_art_data
18
+ from .search.genetic import GeneticSearch
19
+ from .spaces.prompt_space import PromptSearchSpace
20
+
21
+
22
+ class NevoPipeline(DiffusionPipeline):
23
+ config_name = "stimulus_synthesis_config.json"
24
+
25
+ def __init__(
26
+ self,
27
+ synthesis_config: StimulusSynthesisConfig | dict[str, Any] | None = None,
28
+ text_to_image=None,
29
+ image_to_video=None,
30
+ scorer=None,
31
+ search_space=None,
32
+ ) -> None:
33
+ super().__init__()
34
+ if isinstance(synthesis_config, dict):
35
+ synthesis_config = StimulusSynthesisConfig.from_dict(synthesis_config)
36
+ object.__setattr__(self, "synthesis_config", synthesis_config or StimulusSynthesisConfig())
37
+ object.__setattr__(self, "text_to_image", text_to_image)
38
+ object.__setattr__(self, "image_to_video", image_to_video)
39
+ object.__setattr__(self, "scorer", scorer)
40
+ object.__setattr__(self, "search_space", search_space)
41
+
42
+ @classmethod
43
+ def from_pretrained(cls, pretrained_model_name_or_path: str | Path, *args, **kwargs):
44
+ text_to_image = kwargs.pop("text_to_image", None)
45
+ image_to_video = kwargs.pop("image_to_video", None)
46
+ scorer = kwargs.pop("scorer", None)
47
+ search_space = kwargs.pop("search_space", None)
48
+ config_override = kwargs.pop("synthesis_config", None)
49
+ device = kwargs.pop("device", None)
50
+
51
+ path = Path(pretrained_model_name_or_path)
52
+ if path.exists():
53
+ config_path = path / cls.config_name
54
+ synthesis_config = StimulusSynthesisConfig.from_json_file(config_path) if config_path.exists() else StimulusSynthesisConfig()
55
+ if config_override is not None:
56
+ synthesis_config = StimulusSynthesisConfig.from_dict({**synthesis_config.to_dict(), **_as_dict(config_override)})
57
+ return cls(
58
+ synthesis_config=synthesis_config,
59
+ text_to_image=text_to_image,
60
+ image_to_video=image_to_video,
61
+ scorer=scorer,
62
+ search_space=search_space,
63
+ )
64
+
65
+ from huggingface_hub import hf_hub_download
66
+
67
+ config_file = hf_hub_download(str(pretrained_model_name_or_path), cls.config_name, repo_type="model", **_hub_kwargs(kwargs))
68
+ synthesis_config = StimulusSynthesisConfig.from_json_file(config_file)
69
+ if config_override is not None:
70
+ synthesis_config = StimulusSynthesisConfig.from_dict({**synthesis_config.to_dict(), **_as_dict(config_override)})
71
+ if device is not None:
72
+ synthesis_config.default_device = device
73
+ return cls(
74
+ synthesis_config=synthesis_config,
75
+ text_to_image=text_to_image,
76
+ image_to_video=image_to_video,
77
+ scorer=scorer,
78
+ search_space=search_space,
79
+ )
80
+
81
+ def _ensure_components(self, device: str | None = None) -> None:
82
+ cfg = self.synthesis_config
83
+ device = device or cfg.default_device
84
+ if device == "cuda" and not torch.cuda.is_available():
85
+ device = "cpu"
86
+ if self.text_to_image is None:
87
+ object.__setattr__(self, "text_to_image", DiffusersTextToImageAdapter(cfg.text_to_image_model_id, device=device))
88
+ if self.image_to_video is None:
89
+ object.__setattr__(self, "image_to_video", DiffusersImageToVideoAdapter(cfg.image_to_video_model_id, device=device))
90
+ if self.scorer is None:
91
+ base_scorer = EncoderScorer(
92
+ cfg.encoder_model_id,
93
+ encoder_call=cfg.encoder_call,
94
+ objective=cfg.default_objective,
95
+ device=device,
96
+ )
97
+ transform_spec = RobustTransformSpec.from_dict(dict(cfg.default_score_transform or {}))
98
+ scorer = RobustTransformScorer(base_scorer, transform_spec) if transform_spec is not None else base_scorer
99
+ object.__setattr__(self, "scorer", scorer)
100
+
101
+ def make_search_space(
102
+ self,
103
+ roi: str | None = None,
104
+ *,
105
+ enforce_general_search_space: bool = False,
106
+ search_space=None,
107
+ prompt_banks: dict[str, Any] | None = None,
108
+ seed_prompts: list[str] | None = None,
109
+ ):
110
+ """Select the prompt search space for the single-stage fallback path.
111
+
112
+ When an ``roi`` is given, an ROI-aware *enhanced* structured space is used:
113
+ only the prompt categories relevant to that region are searched (the rest are
114
+ locked). Pass ``enforce_general_search_space=True`` to search the general
115
+ (all-category) space instead. An explicit ``search_space``, a pipeline-level
116
+ ``search_space``, or ``prompt_banks`` take precedence.
117
+ """
118
+ if search_space is not None:
119
+ return search_space
120
+ if self.search_space is not None:
121
+ return self.search_space
122
+ if prompt_banks is not None:
123
+ return PromptSearchSpace(prompt_banks=prompt_banks, seed_prompts=seed_prompts)
124
+ if roi is not None:
125
+ space_roi = None if enforce_general_search_space else roi
126
+ return StructuredArtPromptSpace(make_t2v_art_data(), roi=space_roi)
127
+ return PromptSearchSpace(prompt_banks=prompt_banks, seed_prompts=seed_prompts)
128
+
129
+ def _run_two_stage(
130
+ self,
131
+ *,
132
+ target,
133
+ roi,
134
+ enforce_general_search_space,
135
+ progress,
136
+ image_max_evals,
137
+ video_max_evals,
138
+ image_batch_size,
139
+ video_batch_size,
140
+ population_size,
141
+ seed,
142
+ text_to_image,
143
+ image_to_video,
144
+ scorer,
145
+ image_kwargs,
146
+ video_kwargs,
147
+ score_kwargs,
148
+ score_size,
149
+ num_frames,
150
+ ) -> StimulusSynthesisOutput:
151
+ """Two-stage evolutionary search (matches the paper / batch runners):
152
+
153
+ Stage 1 evolves the *image* prompt, scored on the generated image;
154
+ Stage 2 freezes the best image and evolves the *motion* prompt, scored on video.
155
+ """
156
+ cfg = self.synthesis_config
157
+ space_roi = None if enforce_general_search_space else roi
158
+ pop = max(2, int(population_size or cfg.default_population_size))
159
+ image_evals = max(2, int(image_max_evals or cfg.default_image_max_evals))
160
+ video_evals = max(2, int(video_max_evals or cfg.default_video_max_evals))
161
+ score_frames = num_frames if num_frames is not None else cfg.default_score_frames
162
+
163
+ # ---- Stage 1: image prompt search ----
164
+ image_search = GeneticSearch(
165
+ max_evals=image_evals,
166
+ population_size=max(2, min(pop, image_evals)),
167
+ n_init=max(2, min(pop, image_evals)),
168
+ mutation_rate=cfg.default_mutation_rate,
169
+ elite_frac=cfg.default_elite_frac,
170
+ image_kwargs=image_kwargs,
171
+ video_kwargs={},
172
+ score_kwargs=score_kwargs,
173
+ score_size=score_size,
174
+ num_frames=score_frames,
175
+ image_batch_size=image_batch_size,
176
+ video_batch_size=video_batch_size,
177
+ show_progress=progress,
178
+ progress_desc="Stage 1 - image",
179
+ )
180
+ image_result = image_search.run(
181
+ StructuredArtPromptSpace(art_data, roi=space_roi),
182
+ text_to_image,
183
+ _StaticImageToVideo(num_frames=score_frames),
184
+ scorer,
185
+ target,
186
+ seed=seed,
187
+ )
188
+ best_image = image_result.best_image
189
+ if best_image is None:
190
+ best_image = text_to_image.generate([image_result.best_prompt], **(image_kwargs or {}))[0]
191
+
192
+ # ---- Stage 2: motion prompt search on the fixed best image ----
193
+ video_search = GeneticSearch(
194
+ max_evals=video_evals,
195
+ population_size=max(2, min(pop, video_evals)),
196
+ n_init=max(2, min(pop, video_evals)),
197
+ mutation_rate=cfg.default_mutation_rate,
198
+ elite_frac=cfg.default_elite_frac,
199
+ image_kwargs={},
200
+ video_kwargs=video_kwargs,
201
+ score_kwargs=score_kwargs,
202
+ score_size=score_size,
203
+ num_frames=score_frames,
204
+ image_batch_size=image_batch_size,
205
+ video_batch_size=video_batch_size,
206
+ show_progress=progress,
207
+ progress_desc="Stage 2 - video",
208
+ )
209
+ video_result = video_search.run(
210
+ VideoMotionPromptSpace(roi=space_roi),
211
+ _FixedImageT2I(best_image),
212
+ image_to_video,
213
+ scorer,
214
+ target,
215
+ seed=seed,
216
+ )
217
+ best_video = video_result.best_video
218
+ if best_video is None:
219
+ best_video = image_to_video.generate(best_image, video_result.best_prompt, **(video_kwargs or {}))
220
+
221
+ best_prompt = ", ".join(p for p in (image_result.best_prompt, video_result.best_prompt) if p)
222
+ candidate = StimulusCandidate(
223
+ prompt=best_prompt,
224
+ score=video_result.best_score,
225
+ image=best_image,
226
+ video=best_video,
227
+ metadata={
228
+ "rank": 1,
229
+ "image_prompt": image_result.best_prompt,
230
+ "image_score": image_result.best_score,
231
+ "video_prompt": video_result.best_prompt,
232
+ },
233
+ )
234
+ return StimulusSynthesisOutput(
235
+ candidates=[candidate],
236
+ best_prompt=best_prompt,
237
+ best_score=video_result.best_score,
238
+ history_best=video_result.history_best,
239
+ metadata={
240
+ "encoder_model_id": cfg.encoder_model_id,
241
+ "text_to_image_model_id": cfg.text_to_image_model_id,
242
+ "image_to_video_model_id": cfg.image_to_video_model_id,
243
+ "objective": cfg.default_objective,
244
+ "two_stage": True,
245
+ "image_max_evals": image_evals,
246
+ "video_max_evals": video_evals,
247
+ "population_size": pop,
248
+ "seed": seed,
249
+ },
250
+ )
251
+
252
+ def __call__(
253
+ self,
254
+ target=None,
255
+ seed_prompts: list[str] | None = None,
256
+ *,
257
+ roi: str | None = None,
258
+ enforce_general_search_space: bool = False,
259
+ progress: bool = False,
260
+ image_batch_size: int | None = None,
261
+ video_batch_size: int | None = None,
262
+ image_max_evals: int | None = None,
263
+ video_max_evals: int | None = None,
264
+ population_size: int | None = None,
265
+ seed: int | None = None,
266
+ prompt_banks: dict[str, list[str]] | None = None,
267
+ search_space=None,
268
+ text_to_image=None,
269
+ image_to_video=None,
270
+ scorer=None,
271
+ device: str | None = None,
272
+ image_kwargs: dict[str, Any] | None = None,
273
+ video_kwargs: dict[str, Any] | None = None,
274
+ score_kwargs: dict[str, Any] | None = None,
275
+ score_size: int | tuple[int, int] | None = None,
276
+ num_frames: int | None = None,
277
+ ) -> StimulusSynthesisOutput:
278
+ cfg = self.synthesis_config
279
+ if seed is None:
280
+ import secrets
281
+ seed = int(secrets.randbelow(2**31))
282
+ self._ensure_components(device=device)
283
+ text_to_image = text_to_image or self.text_to_image
284
+ image_to_video = image_to_video or self.image_to_video
285
+ scorer = scorer or self.scorer
286
+ img_bs = image_batch_size if image_batch_size is not None else cfg.default_image_batch_size
287
+ vid_bs = video_batch_size if video_batch_size is not None else cfg.default_video_batch_size
288
+ pop = max(2, population_size if population_size is not None else cfg.default_population_size)
289
+ image_evals = max(2, image_max_evals if image_max_evals is not None else cfg.default_image_max_evals)
290
+ video_evals = max(2, video_max_evals if video_max_evals is not None else cfg.default_video_max_evals)
291
+ image_kwargs = {**(cfg.default_image_kwargs or {}), **(image_kwargs or {})}
292
+ video_kwargs = {**(cfg.default_video_kwargs or {}), **(video_kwargs or {})}
293
+ score_size = score_size if score_size is not None else cfg.default_score_size
294
+
295
+ if roi is not None and target is None:
296
+ from .neuro import resolve_driving_voxels
297
+ mask = resolve_driving_voxels(roi)
298
+ target = {"type": "indices", "indices": np.flatnonzero(mask).astype(int).tolist()}
299
+ if target is None:
300
+ raise ValueError("Provide either `target` or `roi`.")
301
+
302
+ # Default: two-stage structured search (evolve the image prompt, then the motion
303
+ # prompt on the fixed best image). An explicit search space / prompt bank / seed
304
+ # prompts falls back to a single joint search over that space.
305
+ if search_space is None and self.search_space is None and prompt_banks is None and seed_prompts is None:
306
+ return self._run_two_stage(
307
+ target=target,
308
+ roi=roi,
309
+ enforce_general_search_space=enforce_general_search_space,
310
+ progress=progress,
311
+ image_max_evals=image_evals,
312
+ video_max_evals=video_evals,
313
+ image_batch_size=img_bs,
314
+ video_batch_size=vid_bs,
315
+ population_size=pop,
316
+ seed=seed,
317
+ text_to_image=text_to_image,
318
+ image_to_video=image_to_video,
319
+ scorer=scorer,
320
+ image_kwargs=image_kwargs,
321
+ video_kwargs=video_kwargs,
322
+ score_kwargs=score_kwargs,
323
+ score_size=score_size,
324
+ num_frames=num_frames,
325
+ )
326
+
327
+ # ---- single-stage fallback (explicit search space / prompt bank / seed prompts) ----
328
+ space = self.make_search_space(
329
+ roi=roi,
330
+ enforce_general_search_space=enforce_general_search_space,
331
+ search_space=search_space,
332
+ prompt_banks=prompt_banks,
333
+ seed_prompts=seed_prompts,
334
+ )
335
+ search = GeneticSearch(
336
+ max_evals=image_evals,
337
+ population_size=max(2, min(pop, image_evals)),
338
+ n_init=max(2, min(pop, image_evals)),
339
+ mutation_rate=cfg.default_mutation_rate,
340
+ elite_frac=cfg.default_elite_frac,
341
+ image_kwargs=image_kwargs,
342
+ video_kwargs=video_kwargs,
343
+ score_kwargs=score_kwargs,
344
+ score_size=score_size,
345
+ num_frames=num_frames,
346
+ image_batch_size=img_bs,
347
+ video_batch_size=vid_bs,
348
+ show_progress=progress,
349
+ )
350
+ result = search.run(space, text_to_image, image_to_video, scorer, target, seed=seed)
351
+ best_image = result.best_image
352
+ if best_image is None:
353
+ best_image = text_to_image.generate([result.best_prompt], **(image_kwargs or {}))[0]
354
+ best_video = result.best_video
355
+ if best_video is None:
356
+ best_video = image_to_video.generate(best_image, result.best_prompt, **(video_kwargs or {}))
357
+ candidate = StimulusCandidate(
358
+ prompt=result.best_prompt,
359
+ score=result.best_score,
360
+ image=best_image,
361
+ video=best_video,
362
+ metadata={"rank": 1, **result.best_metadata},
363
+ )
364
+ return StimulusSynthesisOutput(
365
+ candidates=[candidate],
366
+ best_prompt=result.best_prompt,
367
+ best_score=result.best_score,
368
+ history_best=result.history_best,
369
+ metadata={
370
+ "encoder_model_id": cfg.encoder_model_id,
371
+ "text_to_image_model_id": cfg.text_to_image_model_id,
372
+ "image_to_video_model_id": cfg.image_to_video_model_id,
373
+ "objective": cfg.default_objective,
374
+ "max_evals": image_evals,
375
+ "seed": seed,
376
+ },
377
+ )
378
+
379
+
380
+ class _StaticImageToVideo:
381
+ """Stage-1 image-to-video stand-in: replicate the still image into an ``num_frames``
382
+ clip (no motion) so the video encoder can score it. ``num_frames`` is the value
383
+ passed to the pipeline (or its default)."""
384
+
385
+ def __init__(self, num_frames: int = 16):
386
+ self._num_frames = max(2, int(num_frames))
387
+
388
+ def generate(self, image, prompt, *, generator=None, **kwargs):
389
+ from .media.normalize import _frame_to_tensor
390
+ frame = _frame_to_tensor(image) # (C,H,W) in [0,1] — convert the still ONCE
391
+ return frame.unsqueeze(0).expand(self._num_frames, -1, -1, -1)
392
+
393
+ def generate_batch(self, images, prompts, *, generators=None, **kwargs):
394
+ return [self.generate(img, prompt) for img, prompt in zip(images, prompts)]
395
+
396
+
397
+ class _FixedImageT2I:
398
+ """Stage-2 text-to-image stand-in: always returns the fixed best image from stage 1."""
399
+
400
+ def __init__(self, image):
401
+ self._image = image
402
+
403
+ def generate(self, prompts, *, generator=None, **kwargs):
404
+ return [self._image for _ in prompts]
405
+
406
+
407
+ def _as_dict(value: Any) -> dict[str, Any]:
408
+ if isinstance(value, StimulusSynthesisConfig):
409
+ return value.to_dict()
410
+ if isinstance(value, dict):
411
+ return value
412
+ if isinstance(value, (str, Path)):
413
+ with open(value, "r") as f:
414
+ return json.load(f)
415
+ raise TypeError(f"Unsupported synthesis_config override: {type(value)!r}")
416
+
417
+
418
+ def _hub_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
419
+ allowed = {"revision", "token", "cache_dir", "local_files_only"}
420
+ return {k: kwargs[k] for k in list(kwargs.keys()) if k in allowed}
stimulus_synthesis/scoring/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .asset_scorer import AssetScoreRecord, AssetScorer, score_image_asset, score_video_asset
2
+ from .base import Scorer
3
+ from .encoder_preprocess import EncoderPreparedInput, EncoderPreprocessSpec, prepare_image_for_encoder, prepare_video_for_encoder
4
+ from .encoder_scorer import EncoderScorer
5
+ from .objectives import build_objective
6
+ from .robust_transform import RobustTransformScorer, RobustTransformSpec, apply_robust_transform
7
+ from .targets import TargetSpec, parse_target
8
+
9
+ __all__ = [
10
+ "AssetScoreRecord",
11
+ "AssetScorer",
12
+ "EncoderPreparedInput",
13
+ "EncoderPreprocessSpec",
14
+ "Scorer",
15
+ "EncoderScorer",
16
+ "RobustTransformScorer",
17
+ "RobustTransformSpec",
18
+ "TargetSpec",
19
+ "apply_robust_transform",
20
+ "build_objective",
21
+ "parse_target",
22
+ "prepare_image_for_encoder",
23
+ "prepare_video_for_encoder",
24
+ "score_image_asset",
25
+ "score_video_asset",
26
+ ]
stimulus_synthesis/scoring/asset_scorer.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from stimulus_synthesis.media.asset_decode import decode_image, decode_video
8
+ from stimulus_synthesis.scoring.encoder_preprocess import EncoderPreprocessSpec, prepare_image_for_encoder, prepare_video_for_encoder
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class AssetScoreRecord:
13
+ path: str
14
+ asset_type: str
15
+ sha256: str
16
+ score: float
17
+ sampled_frame_indices: list[int]
18
+ decoded: dict[str, Any]
19
+ preprocess: dict[str, Any]
20
+ asset_spec: dict[str, Any] | None = None
21
+ metadata: dict[str, Any] = field(default_factory=dict)
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ return asdict(self)
25
+
26
+
27
+ class AssetScorer:
28
+ def __init__(
29
+ self,
30
+ scorer: Any,
31
+ target: Any,
32
+ *,
33
+ preprocess_spec: EncoderPreprocessSpec | None = None,
34
+ score_kwargs: dict[str, Any] | None = None,
35
+ ) -> None:
36
+ self.scorer = scorer
37
+ self.target = target
38
+ self.preprocess_spec = preprocess_spec or EncoderPreprocessSpec()
39
+ self.score_kwargs = score_kwargs or {}
40
+
41
+ def score_image(self, path: str | Path, *, asset_spec: Any | None = None, metadata: dict[str, Any] | None = None) -> AssetScoreRecord:
42
+ decoded = decode_image(path)
43
+ prepared = prepare_image_for_encoder(decoded.image, self.preprocess_spec)
44
+ score = self.scorer.score(prepared.videos, self.target, **self.score_kwargs)[0]
45
+ return AssetScoreRecord(
46
+ path=decoded.path,
47
+ asset_type="image",
48
+ sha256=decoded.sha256,
49
+ score=float(score),
50
+ sampled_frame_indices=prepared.frame_indices,
51
+ decoded=decoded.metadata(),
52
+ preprocess=prepared.spec,
53
+ asset_spec=_spec_to_dict(asset_spec),
54
+ metadata=metadata or {},
55
+ )
56
+
57
+ def score_video(self, path: str | Path, *, asset_spec: Any | None = None, metadata: dict[str, Any] | None = None) -> AssetScoreRecord:
58
+ decoded = decode_video(path)
59
+ prepared = prepare_video_for_encoder(decoded.frames, self.preprocess_spec)
60
+ score = self.scorer.score(prepared.videos, self.target, **self.score_kwargs)[0]
61
+ return AssetScoreRecord(
62
+ path=decoded.path,
63
+ asset_type="video",
64
+ sha256=decoded.sha256,
65
+ score=float(score),
66
+ sampled_frame_indices=prepared.frame_indices,
67
+ decoded=decoded.metadata(),
68
+ preprocess=prepared.spec,
69
+ asset_spec=_spec_to_dict(asset_spec),
70
+ metadata=metadata or {},
71
+ )
72
+
73
+
74
+ def score_image_asset(
75
+ path: str | Path,
76
+ scorer: Any,
77
+ target: Any,
78
+ *,
79
+ preprocess_spec: EncoderPreprocessSpec | None = None,
80
+ asset_spec: Any | None = None,
81
+ score_kwargs: dict[str, Any] | None = None,
82
+ metadata: dict[str, Any] | None = None,
83
+ ) -> AssetScoreRecord:
84
+ return AssetScorer(scorer, target, preprocess_spec=preprocess_spec, score_kwargs=score_kwargs).score_image(
85
+ path, asset_spec=asset_spec, metadata=metadata
86
+ )
87
+
88
+
89
+ def score_video_asset(
90
+ path: str | Path,
91
+ scorer: Any,
92
+ target: Any,
93
+ *,
94
+ preprocess_spec: EncoderPreprocessSpec | None = None,
95
+ asset_spec: Any | None = None,
96
+ score_kwargs: dict[str, Any] | None = None,
97
+ metadata: dict[str, Any] | None = None,
98
+ ) -> AssetScoreRecord:
99
+ return AssetScorer(scorer, target, preprocess_spec=preprocess_spec, score_kwargs=score_kwargs).score_video(
100
+ path, asset_spec=asset_spec, metadata=metadata
101
+ )
102
+
103
+
104
+ def _spec_to_dict(spec: Any | None) -> dict[str, Any] | None:
105
+ if spec is None:
106
+ return None
107
+ if hasattr(spec, "to_dict"):
108
+ return spec.to_dict()
109
+ if isinstance(spec, dict):
110
+ return dict(spec)
111
+ return asdict(spec)
stimulus_synthesis/scoring/base.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+
7
+ class Scorer(ABC):
8
+ @abstractmethod
9
+ def score(self, videos: Any, target: Any, **kwargs) -> list[float]:
10
+ ...
stimulus_synthesis/scoring/encoder_preprocess.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ import torch
8
+ import torch.nn.functional as F
9
+ from PIL import Image
10
+
11
+ from stimulus_synthesis.media.normalize import video_to_t_c_h_w
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class EncoderPreprocessSpec:
16
+ size: int | tuple[int, int] | None = 224
17
+ num_frames: int | None = None
18
+ frame_sampling: str = "uniform"
19
+ normalize_mean: tuple[float, float, float] | None = None
20
+ normalize_std: tuple[float, float, float] | None = None
21
+
22
+ def to_dict(self) -> dict[str, Any]:
23
+ return asdict(self)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class EncoderPreparedInput:
28
+ videos: torch.Tensor
29
+ frame_indices: list[int]
30
+ spec: dict[str, Any]
31
+
32
+
33
+ def prepare_image_for_encoder(image: Any, spec: EncoderPreprocessSpec | None = None) -> EncoderPreparedInput:
34
+ spec = spec or EncoderPreprocessSpec(num_frames=1)
35
+ frame = _image_to_c_h_w(image)
36
+ num_frames = int(spec.num_frames or 1)
37
+ video = frame.unsqueeze(0).repeat(num_frames, 1, 1, 1)
38
+ video = _resize_video(video, spec.size)
39
+ video = _normalize(video, spec)
40
+ return EncoderPreparedInput(videos=video.unsqueeze(0).contiguous(), frame_indices=[0] * num_frames, spec=spec.to_dict())
41
+
42
+
43
+ def prepare_video_for_encoder(frames: Any, spec: EncoderPreprocessSpec | None = None) -> EncoderPreparedInput:
44
+ spec = spec or EncoderPreprocessSpec()
45
+ video = video_to_t_c_h_w(frames)
46
+ indices = sample_frame_indices(video.shape[0], spec.num_frames, spec.frame_sampling)
47
+ if indices:
48
+ video = video[torch.as_tensor(indices, dtype=torch.long)]
49
+ video = _resize_video(video, spec.size)
50
+ video = _normalize(video, spec)
51
+ return EncoderPreparedInput(videos=video.unsqueeze(0).contiguous(), frame_indices=indices, spec=spec.to_dict())
52
+
53
+
54
+ def sample_frame_indices(total_frames: int, num_frames: int | None, policy: str = "uniform") -> list[int]:
55
+ if total_frames <= 0:
56
+ raise ValueError("total_frames must be positive.")
57
+ if num_frames is None:
58
+ return list(range(total_frames))
59
+ if num_frames <= 0:
60
+ raise ValueError("num_frames must be positive when set.")
61
+ if policy != "uniform":
62
+ raise ValueError(f"Unsupported frame sampling policy: {policy!r}")
63
+ if total_frames == num_frames:
64
+ return list(range(total_frames))
65
+ if total_frames > num_frames:
66
+ return torch.linspace(0, total_frames - 1, steps=num_frames).round().long().tolist()
67
+ reps = int(np.ceil(num_frames / total_frames))
68
+ return (list(range(total_frames)) * reps)[:num_frames]
69
+
70
+
71
+ def _image_to_c_h_w(image: Any) -> torch.Tensor:
72
+ if isinstance(image, Image.Image):
73
+ arr = np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0
74
+ return torch.from_numpy(arr).permute(2, 0, 1).contiguous()
75
+ if isinstance(image, np.ndarray):
76
+ arr = image.astype(np.float32, copy=False)
77
+ if arr.max() > 1.0:
78
+ arr = arr / 255.0
79
+ tensor = torch.from_numpy(arr)
80
+ if tensor.ndim != 3:
81
+ raise ValueError(f"Expected image array with 3 dims, got {arr.shape}")
82
+ if tensor.shape[-1] == 3:
83
+ tensor = tensor.permute(2, 0, 1)
84
+ return tensor.float().contiguous()
85
+ if torch.is_tensor(image):
86
+ tensor = image.detach().float()
87
+ if tensor.ndim == 4:
88
+ if tensor.shape[0] != 1:
89
+ raise ValueError(f"Expected single-frame image tensor, got {tuple(tensor.shape)}")
90
+ tensor = tensor.squeeze(0)
91
+ if tensor.ndim != 3:
92
+ raise ValueError(f"Expected image tensor with 3 dims, got {tuple(tensor.shape)}")
93
+ if tensor.shape[-1] == 3:
94
+ tensor = tensor.permute(2, 0, 1)
95
+ if tensor.max() > 1.0:
96
+ tensor = tensor / 255.0
97
+ return tensor.contiguous()
98
+ raise TypeError(f"Unsupported image type: {type(image)!r}")
99
+
100
+
101
+ def _resize_video(video: torch.Tensor, size: int | tuple[int, int] | None) -> torch.Tensor:
102
+ if size is None:
103
+ return video.float().clamp(0.0, 1.0).contiguous()
104
+ size_hw = (int(size), int(size)) if isinstance(size, int) else (int(size[0]), int(size[1]))
105
+ if tuple(video.shape[-2:]) == size_hw:
106
+ return video.float().clamp(0.0, 1.0).contiguous()
107
+ return F.interpolate(video.float(), size=size_hw, mode="bilinear", align_corners=False).clamp(0.0, 1.0).contiguous()
108
+
109
+
110
+ def _normalize(video: torch.Tensor, spec: EncoderPreprocessSpec) -> torch.Tensor:
111
+ if spec.normalize_mean is None and spec.normalize_std is None:
112
+ return video
113
+ mean = torch.tensor(spec.normalize_mean or (0.0, 0.0, 0.0), dtype=video.dtype, device=video.device).view(1, 3, 1, 1)
114
+ std = torch.tensor(spec.normalize_std or (1.0, 1.0, 1.0), dtype=video.dtype, device=video.device).view(1, 3, 1, 1)
115
+ return (video - mean) / std
stimulus_synthesis/scoring/encoder_scorer.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import torch
6
+ from transformers import AutoModel
7
+
8
+ from .base import Scorer
9
+ from .objectives import build_objective
10
+
11
+
12
+ class EncoderScorer(Scorer):
13
+ def __init__(
14
+ self,
15
+ encoder_model_id: str | None = None,
16
+ *,
17
+ encoder: Any | None = None,
18
+ encoder_call: str = "predict_fmri",
19
+ objective: str | Any = "indices_mean",
20
+ device: str = "cuda",
21
+ trust_remote_code: bool = True,
22
+ **encoder_kwargs,
23
+ ) -> None:
24
+ if encoder is None:
25
+ if encoder_model_id is None:
26
+ raise ValueError("encoder_model_id is required when encoder is not provided.")
27
+ encoder = AutoModel.from_pretrained(
28
+ encoder_model_id,
29
+ trust_remote_code=trust_remote_code,
30
+ **encoder_kwargs,
31
+ )
32
+ self.encoder = encoder
33
+ self.encoder_call = encoder_call
34
+ self.objective = build_objective(objective)
35
+ self.device = device
36
+ if hasattr(self.encoder, "to"):
37
+ self.encoder.to(device)
38
+ if hasattr(self.encoder, "eval"):
39
+ self.encoder.eval()
40
+
41
+ def score(self, videos: torch.Tensor, target: Any, **kwargs) -> list[float]:
42
+ videos = videos.to(self.device)
43
+ with torch.no_grad():
44
+ if self.encoder_call:
45
+ fn = getattr(self.encoder, self.encoder_call)
46
+ predictions = fn(videos, **kwargs)
47
+ else:
48
+ predictions = self.encoder(videos, **kwargs)
49
+ scores = self.objective(predictions, target)
50
+ return [float(x) for x in scores.detach().cpu().reshape(-1)]
stimulus_synthesis/scoring/objectives.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+
8
+ from .targets import TargetSpec, parse_target
9
+
10
+
11
+ def indices_mean(predictions: torch.Tensor, target) -> torch.Tensor:
12
+ spec = parse_target(target)
13
+ if spec.type != "indices":
14
+ raise ValueError("indices_mean objective requires an indices target.")
15
+ idx = spec.value.to(predictions.device)
16
+ return predictions.index_select(dim=1, index=idx).mean(dim=1)
17
+
18
+
19
+ def vector_dot(predictions: torch.Tensor, target) -> torch.Tensor:
20
+ spec = parse_target(target)
21
+ weights = _target_vector(spec, predictions).to(predictions.device)
22
+ return predictions @ weights
23
+
24
+
25
+ def vector_cosine(predictions: torch.Tensor, target) -> torch.Tensor:
26
+ spec = parse_target(target)
27
+ vector = _target_vector(spec, predictions).to(predictions.device)
28
+ return F.cosine_similarity(predictions, vector.unsqueeze(0), dim=1)
29
+
30
+
31
+ def weighted_mean(predictions: torch.Tensor, target) -> torch.Tensor:
32
+ spec = parse_target(target)
33
+ weights = _target_vector(spec, predictions).to(predictions.device)
34
+ denom = weights.abs().sum().clamp_min(1e-8)
35
+ return (predictions * weights.unsqueeze(0)).sum(dim=1) / denom
36
+
37
+
38
+ def build_objective(name: str | Callable) -> Callable:
39
+ if callable(name):
40
+ return name
41
+ objectives = {
42
+ "indices_mean": indices_mean,
43
+ "target_vector_dot": vector_dot,
44
+ "vector_dot": vector_dot,
45
+ "target_vector_cosine": vector_cosine,
46
+ "vector_cosine": vector_cosine,
47
+ "weighted_mean": weighted_mean,
48
+ }
49
+ if name not in objectives:
50
+ raise ValueError(f"Unknown objective: {name}")
51
+ return objectives[name]
52
+
53
+
54
+ def _target_vector(spec: TargetSpec, predictions: torch.Tensor) -> torch.Tensor:
55
+ if spec.type in {"vector", "weights"}:
56
+ vector = spec.value.float()
57
+ if vector.numel() != predictions.shape[1]:
58
+ raise ValueError(f"Target vector has {vector.numel()} values, expected {predictions.shape[1]}.")
59
+ return vector.reshape(-1)
60
+ if spec.type == "indices":
61
+ vector = torch.zeros(predictions.shape[1], dtype=predictions.dtype)
62
+ vector[spec.value.long()] = 1.0
63
+ return vector
64
+ raise ValueError(f"Unsupported target type for vector objective: {spec.type}")
stimulus_synthesis/scoring/robust_transform.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import asdict, dataclass
5
+ from typing import Any
6
+
7
+ import torch
8
+ import torch.nn.functional as F
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class RobustTransformSpec:
13
+ crop_scale: float = 0.80
14
+ gaussian_sigma: float = 0.10
15
+ num_draws: int = 4
16
+ aggregate: str = "mean"
17
+ seed: int = 0
18
+
19
+ @classmethod
20
+ def from_dict(cls, data: dict[str, Any] | None) -> "RobustTransformSpec | None":
21
+ if data is None:
22
+ return None
23
+ enabled = bool(data.pop("enabled", True)) if "enabled" in data else True
24
+ return cls(**data) if enabled else None
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ return asdict(self)
28
+
29
+
30
+ class RobustTransformScorer:
31
+ """Apply deterministic robust scoring draws before delegating to a scorer."""
32
+
33
+ def __init__(self, scorer: Any, spec: RobustTransformSpec | None = None) -> None:
34
+ self.scorer = scorer
35
+ self.spec = spec or RobustTransformSpec()
36
+
37
+ def score(self, videos: torch.Tensor, target: Any, **kwargs) -> list[float]:
38
+ transformed = apply_robust_transform(videos, self.spec)
39
+ raw_scores = self.scorer.score(transformed, target, **kwargs)
40
+ score_tensor = torch.as_tensor(raw_scores, dtype=torch.float32).reshape(videos.shape[0], self.spec.num_draws)
41
+ if self.spec.aggregate != "mean":
42
+ raise ValueError(f"Unsupported robust score aggregate: {self.spec.aggregate!r}")
43
+ return score_tensor.mean(dim=1).tolist()
44
+
45
+
46
+ def apply_robust_transform(videos: torch.Tensor, spec: RobustTransformSpec | None = None) -> torch.Tensor:
47
+ spec = spec or RobustTransformSpec()
48
+ if videos.ndim != 5:
49
+ raise ValueError(f"Expected videos shaped (B,T,C,H,W), got {tuple(videos.shape)}")
50
+ if spec.num_draws <= 0:
51
+ raise ValueError("num_draws must be positive.")
52
+ if not (0.0 < spec.crop_scale <= 1.0):
53
+ raise ValueError("crop_scale must be in (0, 1].")
54
+ if spec.gaussian_sigma < 0.0:
55
+ raise ValueError("gaussian_sigma must be non-negative.")
56
+
57
+ videos = videos.float().clamp(0.0, 1.0)
58
+ out = []
59
+ for batch_idx in range(videos.shape[0]):
60
+ base_seed = _content_seed(videos[batch_idx], spec.seed)
61
+ for draw_idx in range(spec.num_draws):
62
+ out.append(_transform_one(videos[batch_idx], spec, base_seed + draw_idx * 7919))
63
+ return torch.stack(out, dim=0).contiguous()
64
+
65
+
66
+ def _transform_one(video: torch.Tensor, spec: RobustTransformSpec, seed: int) -> torch.Tensor:
67
+ generator = torch.Generator(device=video.device).manual_seed(int(seed) % (2**63 - 1))
68
+ transformed = _random_resized_crop(video, spec.crop_scale, generator)
69
+ if spec.gaussian_sigma:
70
+ noise = torch.randn(
71
+ transformed.shape,
72
+ generator=generator,
73
+ device=transformed.device,
74
+ dtype=transformed.dtype,
75
+ )
76
+ transformed = transformed + float(spec.gaussian_sigma) * noise
77
+ return transformed.clamp(0.0, 1.0)
78
+
79
+
80
+ def _random_resized_crop(video: torch.Tensor, crop_scale: float, generator: torch.Generator) -> torch.Tensor:
81
+ if crop_scale == 1.0:
82
+ return video
83
+ _t, _c, h, w = video.shape
84
+ crop_h = max(1, int(round(h * crop_scale)))
85
+ crop_w = max(1, int(round(w * crop_scale)))
86
+ max_y = h - crop_h
87
+ max_x = w - crop_w
88
+ y0 = int(torch.randint(max_y + 1, (1,), generator=generator, device=video.device).item()) if max_y else 0
89
+ x0 = int(torch.randint(max_x + 1, (1,), generator=generator, device=video.device).item()) if max_x else 0
90
+ crop = video[:, :, y0 : y0 + crop_h, x0 : x0 + crop_w]
91
+ return F.interpolate(crop, size=(h, w), mode="bilinear", align_corners=False)
92
+
93
+
94
+ def _content_seed(video: torch.Tensor, seed: int) -> int:
95
+ quantized = (video.detach().cpu().clamp(0.0, 1.0) * 255).round().to(torch.uint8).numpy().tobytes()
96
+ digest = hashlib.blake2b(quantized, digest_size=8, person=b"nevo-rbt").digest()
97
+ return (int.from_bytes(digest, "little") + int(seed)) % (2**63 - 1)