Instructions to use 43ntropy/NEvo with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use 43ntropy/NEvo with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("43ntropy/NEvo", dtype=torch.bfloat16, device_map="cuda") prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k" image = pipe(prompt).images[0] - Notebooks
- Google Colab
- Kaggle
Duplicate from epfl-neuroai/NEvo
Browse filesCo-authored-by: david <dawidtang@users.noreply.huggingface.co>
This view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +6 -0
- .gitattributes +41 -0
- .gitignore +6 -0
- README.md +213 -0
- assets/gallery/EBA.gif +3 -0
- assets/gallery/FFA.gif +3 -0
- assets/gallery/MT.gif +3 -0
- assets/gallery/PPA.gif +3 -0
- assets/gallery/V1.gif +3 -0
- assets/gallery/pSTS.gif +3 -0
- examples/override_models.py +13 -0
- examples/quickstart.py +11 -0
- examples/synthesize_for_roi.py +11 -0
- examples/synthesize_for_vector.py +9 -0
- model_index.json +5 -0
- pipeline.py +20 -0
- pyproject.toml +32 -0
- requirements.txt +17 -0
- run_regional_asset_pilot.py +271 -0
- run_roi_samples.py +273 -0
- stimulus_synthesis/__init__.py +8 -0
- stimulus_synthesis/asset_manifest.py +35 -0
- stimulus_synthesis/config.py +74 -0
- stimulus_synthesis/data/__init__.py +0 -0
- stimulus_synthesis/data/roi_masks.npz +3 -0
- stimulus_synthesis/data/searchlight_both.npz +3 -0
- stimulus_synthesis/data/searchlight_lh.npz +3 -0
- stimulus_synthesis/data/searchlight_rh.npz +3 -0
- stimulus_synthesis/generators/__init__.py +5 -0
- stimulus_synthesis/generators/base.py +23 -0
- stimulus_synthesis/generators/diffusers_i2v.py +78 -0
- stimulus_synthesis/generators/diffusers_t2i.py +39 -0
- stimulus_synthesis/media/__init__.py +22 -0
- stimulus_synthesis/media/asset_decode.py +88 -0
- stimulus_synthesis/media/asset_export.py +153 -0
- stimulus_synthesis/media/asset_spec.py +58 -0
- stimulus_synthesis/media/normalize.py +99 -0
- stimulus_synthesis/media/video_io.py +34 -0
- stimulus_synthesis/neuro/__init__.py +3 -0
- stimulus_synthesis/neuro/roi.py +110 -0
- stimulus_synthesis/outputs.py +26 -0
- stimulus_synthesis/paths.py +77 -0
- stimulus_synthesis/pipeline.py +420 -0
- stimulus_synthesis/scoring/__init__.py +26 -0
- stimulus_synthesis/scoring/asset_scorer.py +111 -0
- stimulus_synthesis/scoring/base.py +10 -0
- stimulus_synthesis/scoring/encoder_preprocess.py +115 -0
- stimulus_synthesis/scoring/encoder_scorer.py +50 -0
- stimulus_synthesis/scoring/objectives.py +64 -0
- 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 |  | **PPA** · places |  |
|
| 36 |
+
| **MT** · motion |  | **EBA** · bodies |  |
|
| 37 |
+
| **pSTS** · social motion |  | **V1** · early visual |  |
|
| 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
|
assets/gallery/FFA.gif
ADDED
|
Git LFS Details
|
assets/gallery/MT.gif
ADDED
|
Git LFS Details
|
assets/gallery/PPA.gif
ADDED
|
Git LFS Details
|
assets/gallery/V1.gif
ADDED
|
Git LFS Details
|
assets/gallery/pSTS.gif
ADDED
|
Git LFS Details
|
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)
|