wlyu commited on
Commit
debde0c
·
verified ·
1 Parent(s): 774a1a4

Add files using upload-large-folder tool

Browse files
Files changed (50) hide show
  1. UCPE/.gitignore +182 -0
  2. UCPE/CLAUDE.md +139 -0
  3. UCPE/README.md +425 -0
  4. UCPE/commands.sh +45 -0
  5. UCPE/demo/lens.json +74 -0
  6. UCPE/demo/pose.json +134 -0
  7. UCPE/demo/teaser.json +164 -0
  8. UCPE/diffsynth/__init__.py +6 -0
  9. UCPE/images/cameras.png +0 -0
  10. UCPE/images/orientation.png +0 -0
  11. UCPE/requirements.txt +36 -0
  12. UCPE/scripts/compare_panshot.sh +145 -0
  13. UCPE/scripts/demo.sh +11 -0
  14. UCPE/scripts/evaluate.sh +42 -0
  15. UCPE/scripts/inference.py +45 -0
  16. UCPE/scripts/predict.sh +22 -0
  17. UCPE/scripts/predict_one_sample.py +192 -0
  18. UCPE/scripts/set_Wan2.1-T2V-1.3B.sh +12 -0
  19. UCPE/scripts/set_Wan2.2-TI2V-5B.sh +13 -0
  20. UCPE/scripts/train.sh +66 -0
  21. UCPE/scripts/upload_704.sh +36 -0
  22. UCPE/setup.py +30 -0
  23. UCPE/src/cache.py +108 -0
  24. UCPE/src/camera_control.py +678 -0
  25. UCPE/src/dataset.py +432 -0
  26. UCPE/src/evaluate.py +870 -0
  27. UCPE/src/main.py +387 -0
  28. UCPE/thirdparty/GeoCalib/.gitattributes +1 -0
  29. UCPE/tools/align_panflow.py +599 -0
  30. UCPE/tools/caption_camerabench.py +195 -0
  31. UCPE/tools/caption_panshot.py +210 -0
  32. UCPE/tools/dataset_statistics.py +304 -0
  33. UCPE/tools/download_panflow.py +57 -0
  34. UCPE/tools/export_camerabench.py +64 -0
  35. UCPE/tools/export_camerabench_for_rerender.py +140 -0
  36. UCPE/tools/export_figure.py +192 -0
  37. UCPE/tools/export_table.py +261 -0
  38. UCPE/tools/extract_camerabench_poses.py +67 -0
  39. UCPE/tools/filter_camerabench.py +252 -0
  40. UCPE/tools/filter_panflow.py +254 -0
  41. UCPE/tools/geocalib_camerabench.py +46 -0
  42. UCPE/tools/match_panflow.py +376 -0
  43. UCPE/tools/normalize_panflow.py +200 -0
  44. UCPE/tools/pre_normalize_panflow.py +191 -0
  45. UCPE/tools/process_camerabench.py +195 -0
  46. UCPE/tools/process_panshot.py +470 -0
  47. UCPE/tools/rerender_panshot.py +328 -0
  48. UCPE/tools/score_panflow.py +244 -0
  49. UCPE/tools/visualize_pose.py +1231 -0
  50. UCPE/tools/visualize_re10k.py +222 -0
UCPE/.gitignore ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+
110
+ # pdm
111
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112
+ #pdm.lock
113
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114
+ # in version control.
115
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116
+ .pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121
+ __pypackages__/
122
+
123
+ # Celery stuff
124
+ celerybeat-schedule
125
+ celerybeat.pid
126
+
127
+ # SageMath parsed files
128
+ *.sage.py
129
+
130
+ # Environments
131
+ .env
132
+ .venv
133
+ env/
134
+ venv/
135
+ ENV/
136
+ env.bak/
137
+ venv.bak/
138
+
139
+ # Spyder project settings
140
+ .spyderproject
141
+ .spyproject
142
+
143
+ # Rope project settings
144
+ .ropeproject
145
+
146
+ # mkdocs documentation
147
+ /site
148
+
149
+ # mypy
150
+ .mypy_cache/
151
+ .dmypy.json
152
+ dmypy.json
153
+
154
+ # Pyre type checker
155
+ .pyre/
156
+
157
+ # pytype static type analyzer
158
+ .pytype/
159
+
160
+ # Cython debug symbols
161
+ cython_debug/
162
+
163
+ # PyCharm
164
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
167
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168
+ #.idea/
169
+
170
+ # PyPI configuration file
171
+ .pypirc
172
+
173
+ # Project-specific files
174
+ /data
175
+ /debug
176
+ /.vscode
177
+ pyrightconfig.json
178
+ /logs
179
+ wandb/
180
+ *.pth
181
+ /outputs
182
+ /models
UCPE/CLAUDE.md ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ UCPE (Unified Camera Positional Encoding) is a CVPR 2026 paper implementing geometry-consistent camera control for text-to-video generation. It introduces Relative Ray Encoding + Absolute Orientation Encoding, adding only 0.5% parameters (35.5M) over a 7.3B base model (Wan2.1-T2V-1.3B). The primary pretrained run ID is `6wodf04s`. The installable package name is `diffsynth` (via `pip install -e .`).
8
+
9
+ ## Common Commands
10
+
11
+ ### Environment Setup
12
+ ```bash
13
+ conda create -n UCPE python=3.11 -y
14
+ conda activate UCPE
15
+ conda install -c conda-forge "ffmpeg<8" libiconv libgl -y
16
+ pip install -r requirements.txt
17
+ pip install --no-build-isolation --no-cache-dir flash-attn==2.8.0.post2
18
+ pip install -e .
19
+ cd thirdparty/equilib && pip install -e . && cd ../..
20
+ wandb login
21
+ ```
22
+
23
+ ### Training
24
+ ```bash
25
+ # Full pipeline (train + predict on both datasets):
26
+ bash scripts/train.sh
27
+ # Or manually with custom config:
28
+ source scripts/set_Wan2.1-T2V-1.3B.sh
29
+ export WANDB_NAME="relray_absmap_comp8"
30
+ python src/main.py fit --model.camera_condition="relray_absmap" --model.attn_compress=8
31
+ ```
32
+
33
+ Note: `scripts/train.sh` runs fit + predict sequentially and contains many commented-out ablation/baseline configs. The `set_*.sh` scripts set `CKPT_PATH="last"` by default, so training auto-resumes from the last checkpoint. To start fresh, override or delete existing checkpoints.
34
+
35
+ ### Prediction
36
+ ```bash
37
+ # Manually (scripts/predict.sh contains mostly commented-out historical runs):
38
+ source scripts/set_Wan2.1-T2V-1.3B.sh
39
+ WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.attn_compress=8
40
+ # On RealEstate10k:
41
+ WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.attn_compress=8 --data=Re10kDataModule --trainer.limit_predict_batches=13
42
+ ```
43
+
44
+ ### Demo Inference
45
+ ```bash
46
+ # Using convenience script (uses pretrained run 6wodf04s, runs all three demo configs: lens, pose, teaser):
47
+ bash scripts/demo.sh
48
+ # Or manually (note: use `python -m src.main` not `python src/main.py` for demo):
49
+ source scripts/set_Wan2.1-T2V-1.3B.sh
50
+ export WANDB_MODE=disabled
51
+ export WANDB_RUN_ID=<run_id>
52
+ export PL_PREDICT__DATA="DemoDataModule"
53
+ export PL_PREDICT__MODEL__CKPT_PATH="logs/<run_id>/checkpoints/pytorch_model.bin"
54
+ python -m src.main predict --data.input_file="demo/teaser.json"
55
+ ```
56
+
57
+ ### Evaluation
58
+ ```bash
59
+ bash scripts/evaluate.sh # uses hardcoded WANDB_RUN_ID=6wodf04s
60
+ # Or manually (set WANDB_RUN_ID to your trained model):
61
+ export EVAL_DATA_ROOT="data/UCPE"
62
+ export EVAL_NUM_FRAMES=81
63
+ WANDB_RUN_ID=<run_id> python src/evaluate.py
64
+ # For RealEstate10k evaluation:
65
+ export EVAL_DATA="Re10kDataset"
66
+ export EVAL_DATA_ROOT="data/RealEstate10k"
67
+ export EVAL_POSE_FRAMES=16
68
+ export EVAL_FRAME_STRIDE=4
69
+ export EVAL_LIMIT_EVAL_VIDEOS=100
70
+ WANDB_RUN_ID=<run_id> python src/evaluate.py
71
+ ```
72
+ Evaluation requires the FVD model `i3d_pretrained_400.pt` in `models/FVD/`. Note: `evaluate.sh` sets `HF_HUB_OFFLINE=1` independently and does NOT source `set_*.sh`.
73
+
74
+ ### Latent Caching (pre-training)
75
+ ```bash
76
+ python src/cache.py
77
+ ```
78
+
79
+ ### Data Setup
80
+ - **PanShot**: Download from [Hugging Face](https://huggingface.co/datasets/chengzhag/PanShot) to `data/UCPE/PanShot-7z`, then `cd data/UCPE/PanShot-7z && bash extract_panshot.sh`
81
+ - **RealEstate10k**: Download poses from the [official site](https://google.github.io/realestate10k/) to `data/RealEstate10k/`, plus captions from CameraCtrl
82
+ - **Pretrained weights**: Download from OneDrive link in README, place in `logs/` folder
83
+
84
+ ### Testing & Linting
85
+ There is no test suite and no linting toolchain configured for this project.
86
+
87
+ ## Architecture
88
+
89
+ ### Core Source (`src/`)
90
+
91
+ - **main.py** - Lightning training harness using `LightningCLI`. `PanShotTrainModule` loads the base WanVideoPipeline, calls `patch_dit()` to inject camera control modules, and `enable_grad()` to selectively train camera parameters. Data modules: `PanShotDataModule`, `Re10kDataModule`, `DemoDataModule`.
92
+
93
+ - **camera_control.py** - Core UCPE implementation. Key function `patch_dit(pipe, method, ...)` injects `UcpeSelfAttention` modules into DiT blocks. Supports multiple encoding methods: `relray_absmap` (primary), `relray`, `plucker`, `recammaster`, `prope`, `gta`. Camera math uses the Unified Camera Model (UCM) with parameters `x_fov` (field of view) and `xi` (mirror parameter, 0=pinhole, >0=fisheye).
94
+
95
+ - **dataset.py** - Dataset classes loading video + camera pose (`[T, 3, 4]` extrinsics) + intrinsics (`x_fov`, `xi`) + captions. Includes trajectory normalization and optional yaw zeroing. Note: `Re10kDataModule` has `overwrite_xfov=100.0` default since Re10k doesn't store intrinsics.
96
+
97
+ - **evaluate.py** - Multi-metric evaluation: video quality (FVD, FID, CLIP-score), camera accuracy (FOV error, distortion, pitch/roll), and pose accuracy (rotation/translation error, CAMMC).
98
+
99
+ - **cache.py** - Pre-computes VAE latent embeddings for training efficiency.
100
+
101
+ ### Pipeline (`diffsynth/`)
102
+
103
+ - **pipelines/wan_video_panshot.py** - `WanVideoPipeline` with processing units chained sequentially. The `UCPECameraControl` unit generates camera embeddings from poses/intrinsics that feed into `UcpeSelfAttention` modules in the DiT. Training loss via `training_loss()` using flow-matching diffusion.
104
+
105
+ - **models/wan_video_dit.py** - `WanModel` DiT (Diffusion Transformer) with spatial-temporal attention blocks. Camera conditioning is integrated via the injected `UcpeSelfAttention` modules.
106
+
107
+ ### Key Data Flow
108
+
109
+ 1. Video frames encoded to latents via VAE (cached by `cache.py`)
110
+ 2. Camera poses + intrinsics processed into ray embeddings by `UCPECameraControl`
111
+ 3. Ray embeddings injected into DiT self-attention via `UcpeSelfAttention` (PRoPE-style)
112
+ 4. Diffusion training: predict noise at random timestep, MSE loss, gradients only flow through camera modules
113
+
114
+ ### Configuration
115
+
116
+ Training is configured via environment variables and CLI args to `src/main.py` (Lightning CLI). The env var convention uses `PL_{STAGE}__{SECTION}__{PARAM}` format — e.g., `PL_FIT__MODEL__FPS=16` maps to `--model.fps=16` for the `fit` subcommand. One of the `scripts/set_*.sh` scripts must be sourced before any run to set defaults for all stages (FIT, VALIDATE, TEST, PREDICT) and `HF_HUB_OFFLINE=1`:
117
+ - `scripts/set_Wan2.1-T2V-1.3B.sh` — primary text-to-video model (model_id: `Wan-AI/Wan2.1-T2V-1.3B`)
118
+ - `scripts/set_Wan2.2-TI2V-5B.sh` — text+image-to-video 5B model (model_id: `Wan2.2-TI2V-5B`, data at `/tmp/data/UCPE`)
119
+
120
+ Key model params: `camera_condition` (encoding method), `attn_compress` (attention compression factor, default 8), `adaptation_method` ("parallel"/"before"/"after"), `ti2v_input_image_prob` (probability of conditioning on input image for TI2V, default 0.5), `num_predict` (number of predictions per input, controllable via `PL_PREDICT__MODEL__NUM_PREDICT`). Logging via Weights & Biases; checkpoints saved to `logs/{WANDB_RUN_ID}/checkpoints/`.
121
+
122
+ ### Demo Input Format
123
+
124
+ JSON array of objects:
125
+ ```json
126
+ {"pose_path": "path/to/pose.npy", "x_fov": 100.0, "xi": 0.0, "caption": "text"}
127
+ ```
128
+ Where `pose.npy` contains `[T, 3, 4]` camera extrinsics.
129
+
130
+ ## Third-party Dependencies (`thirdparty/`)
131
+
132
+ - **equilib** - Omnidirectional image projection (must be pip installed separately)
133
+ - **prope** - Position-based Relative Positional Encoding for attention
134
+ - **PanFlow/PanFlowAPI** - Source dataset processing for PanShot curation
135
+ - **GeoCalib, UniK3D, Q-Align, vipe** - Evaluation tools (separate conda envs recommended)
136
+
137
+ ## Data Tools (`tools/`)
138
+
139
+ 20 scripts for dataset curation: PanShot processing (`process_panshot.py`, `caption_panshot.py`), PanFlow pipeline (`filter_panflow.py` -> `align_panflow.py` -> `match_panflow.py` -> `normalize_panflow.py`), visualization (`visualize_pose.py`), and export (`export_figure.py`, `export_table.py`).
UCPE/README.md ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 📷 UCPE
2
+
3
+ <p align="center">
4
+ <h1 align="center">Unified Camera Positional Encoding for Controlled Video Generation</h1>
5
+ <p align="center">
6
+ <p align="center">
7
+ <a href="https://chengzhag.github.io/">Cheng Zhang</a><sup>1</sup><sup>,2</sup>
8
+ ·
9
+ <a href="https://leeby68.github.io/">Boying Li</a><sup>1</sup>
10
+ ·
11
+ <a href="https://www.linkedin.com/in/meng-wei-66687a105/?originalSubdomain=au">Meng Wei</a><sup>1</sup>
12
+ ·
13
+ <a href="https://yanpei.me/">Yan-Pei Cao</a><sup>3</sup>
14
+ ·
15
+ <a href="https://www.monash.edu/mada/architecture/people/camilo-cruz-gambardella/">Camilo Cruz Gambardella</a><sup>1,2</sup>
16
+ ·
17
+ <a href="https://research.monash.edu/en/persons/dinh-phung/">Dinh Phung</a><sup>1</sup>
18
+ ·
19
+ <a href="https://jianfei-cai.github.io/">Jianfei Cai</a><sup>1</sup><br>
20
+ <sup>1</sup>Monash University <sup>2</sup>Building 4.0 CRC <sup>3</sup>VAST
21
+ </p>
22
+ <h2 align="center"><a href="https://arxiv.org/abs/2512.07237">Paper</a> | <a href="https://chengzhag.github.io/publication/ucpe/">Project Page</a> | <a href="https://youtu.be/rMX7gxH8jBM">Video</a> | <a href="https://huggingface.co/datasets/chengzhag/PanShot">Hugging Face</a></h2>
23
+ </p>
24
+
25
+ [![Watch the video](images/thumbnail.png)](https://youtu.be/rMX7gxH8jBM)
26
+ *Our UCPE introduces a geometry-consistent alternative to Plücker rays as one of the core contributions, enabling better generalization in Transformers. We hope to inspire future research on camera-aware architectures.
27
+
28
+ ## 📢 Updates
29
+ - \[2026.03.19\] 🔧 Fixed a bug in Plücker encoding (thanks to [@fengq1a0](https://github.com/fengq1a0)'s [issue #5](https://github.com/chengzhag/UCPE/issues/5)).
30
+ - \[2026.02.21\] 🎉 **UCPE accepted to CVPR 2026**
31
+ - \[2026.02.04\] 📁 **PanShot Dataset And Curation Code** (controllable camera data synthesized from [PanFlow](https://github.com/chengzhag/PanFlow))
32
+ - \[2026.02.04\] 🎯 **Full Training, Evaluation, Visualization Code**
33
+ - \[2025.12.07\] ⚡ **Quick Demo** code released
34
+
35
+ ## 🚀 TLDR
36
+
37
+ 🔥 **Camera-controlled text-to-video generation**, now with **intrinsics**, **distortion** and **orientation** control!
38
+
39
+ <p align="center">
40
+ <img src="images/cameras.png" alt="Camera lenses" height="120px">
41
+ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
42
+ <img src="images/orientation.png" alt="Orientation control" height="140px">
43
+ </p>
44
+
45
+ 📷 UCPE integrates **Relative Ray Encoding**—which delivers significantly better generalization than Plücker across diverse camera motion, intrinsics and lens distortions—with **Absolute Orientation Encoding** for controllable pitch and roll, enabling a unified camera representation for Transformers and state-of-the-art camera-controlled video generation with just **0.5% extra parameters** (35.5M over the 7.3B parameters of the base model)
46
+
47
+ <p align="center">
48
+ <img src="images/video-ucpe.gif"
49
+ alt="UCPE"
50
+ style="max-height:480px; width:auto;">
51
+ </p>
52
+
53
+ ## 🛠️ Installation
54
+
55
+ ```bash
56
+ conda create -n UCPE python=3.11 -y
57
+ conda activate UCPE
58
+ conda install -c conda-forge "ffmpeg<8" libiconv libgl -y
59
+ pip install -r requirements.txt
60
+ pip install --no-build-isolation --no-cache-dir flash-attn==2.8.0.post2
61
+ pip install -e .
62
+
63
+ cd thirdparty/equilib
64
+ pip install -e .
65
+ ```
66
+
67
+ We use wandb to log and visualize the training process. You can create an account then login to wandb by running the following command:
68
+
69
+ ```bash
70
+ wandb login
71
+ ```
72
+
73
+ <details>
74
+ <summary>Below are installations for tools used in evaluation and dataset processing
75
+ that can be skipped if you do not need these tools.</summary>
76
+
77
+ ```bash
78
+ cd ../GeoCalib
79
+ pip install -e .
80
+ pip install -e siclib
81
+
82
+ cd ../UniK3D
83
+ pip install -e . --extra-index-url https://download.pytorch.org/whl/cu121
84
+
85
+ cd ../Q-Align
86
+ conda create -n qalign python=3.9 -y
87
+ conda activate qalign
88
+ pip install -e .
89
+ pip install jsonlines "numpy<2" protobuf pydantic-settings
90
+
91
+ cd ../vipe
92
+ conda env create -f envs/base.yml
93
+ conda activate vipe
94
+ pip install -r envs/requirements.txt
95
+ pip install --no-build-isolation -e .
96
+ ```
97
+ </details>
98
+ <br>
99
+
100
+ ## ⚡ Quick Demo
101
+
102
+ Download our finetuned weights from [OneDrive](https://monashuni-my.sharepoint.com/:f:/g/personal/cheng_zhang_monash_edu/IgCoTNrYOJRJRKtk5A6I1yiCAR9c64-BOrsId5GYsUxE9y4?e=hD26qU) and put it in `logs/` folder. Then run:
103
+
104
+ ```bash
105
+ bash scripts/demo.sh
106
+ ```
107
+
108
+ The generated videos will be saved in `logs/6wodf04s/demo`, examples shown below:
109
+
110
+ * `demo/lens.json`: Our **Relative Ray Encoding** not only generalizes to but also enables controllability over a wide range of camera intrinsics and lens distortions.
111
+
112
+ <p align="center">
113
+ <img src="images/video-lens.gif"
114
+ alt="Lens control"
115
+ style="max-height:480px; width:auto;">
116
+ </p>
117
+
118
+ * `demo/pose.json`: The geometry-consistent design of **Relative Ray Encoding** further allows strong generalization and controllability over diverse camera motions.
119
+
120
+ <p align="center">
121
+ <img src="images/video-pose.gif"
122
+ alt="Pose control"
123
+ style="max-height:480px; width:auto;">
124
+ </p>
125
+
126
+ * `demo/teaser.json`: Our **Absolute Orientation Encoding** further eliminate the ambiguity in pitch and roll in previous T2V methods, enabling precise control over initial camera orientation.
127
+
128
+ <p align="center">
129
+ <img src="images/video-orientation.gif"
130
+ alt="Orientation control"
131
+ style="max-height:480px; width:auto;">
132
+ </p>
133
+
134
+
135
+ ## 🌏 PanShot Dataset
136
+
137
+ Please download the PanShot dataset from [Hugging Face](https://huggingface.co/datasets/chengzhag/PanShot) to `data/UCPE/PanShot-7z` by:
138
+
139
+ ```bash
140
+ huggingface-cli download chengzhag/PanShot --repo-type dataset --local-dir data/UCPE/PanShot-7z
141
+ ```
142
+
143
+ Then extract the dataset by:
144
+ ```bash
145
+ cd data/UCPE/PanShot-7z
146
+ bash extract_panshot.sh
147
+ cd ../../..
148
+ ```
149
+ The extracted dataset will be saved in `data/UCPE/PanShot`.
150
+ Please then copy the other files to form the following folder structure:
151
+
152
+ ```
153
+ ├── captioned-test.jsonl
154
+ ├── captioned-train.jsonl
155
+ ├── max_rotation-test.json
156
+ ├── meta-test
157
+ ├── meta-train
158
+ ├── pose-test
159
+ ├── pose-train
160
+ ├── videos-test
161
+ └── videos-train
162
+ ```
163
+
164
+ <details>
165
+ <summary>If you want to go through the dataset curation process, Please follow these three steps.</summary>
166
+
167
+ > **Shortcut for steps 1 & 2:** You can skip the CameraBench and PanFlow curation steps by downloading our pre-processed data directly:
168
+ > ```bash
169
+ > huggingface-cli download --repo-type dataset chengzhag/UCPE --local-dir data/UCPE
170
+ > cd data/UCPE && bash unpack_hf.sh && cd ../..
171
+ > ```
172
+ > Then proceed directly to step 3 (PanShot).
173
+
174
+ ### CameraBench
175
+
176
+ Download the dataset from multiple sources:
177
+
178
+ ```bash
179
+ cd data
180
+ huggingface-cli download --repo-type dataset syCen/CameraBench --local-dir CameraBench
181
+ cd CameraBench
182
+ huggingface-cli download --repo-type dataset syCen/Videos4CameraBnech --local-dir data/videos
183
+ wget https://huggingface.co/datasets/chancharikm/cambench_train_videos/resolve/main/videos.zip
184
+ unzip videos.zip -d videos
185
+ cd ../..
186
+ ```
187
+
188
+ Process the dataset:
189
+
190
+ ```bash
191
+ conda activate UCPE
192
+ python tools/process_camerabench.py # set split = "train" and split = "test"
193
+
194
+ conda activate vipe
195
+ cd thirdparty/vipe
196
+ python thirdparty/vipe/run.py pipeline=default streams=raw_mp4_stream streams.base_path=data/UCPE/CameraBench/videos/ pipeline.output.path=data/UCPE/CameraBench/vipe/ pipeline.output.save_artifacts=true pipeline.post.depth_align_model=null
197
+
198
+ conda activate UCPE
199
+ python tools/geocalib_camerabench.py
200
+ python tools/filter_camerabench.py
201
+ ```
202
+
203
+ Processed dataset will be saved in `data/UCPE/CameraBench`.
204
+
205
+ ### PanFlow
206
+
207
+ Download the pretrained model `PanoFlow(RAFT)-wo-CFE.pth` of Panoflow at [weiyun](https://share.weiyun.com/SIpeQTNE), then put it in `models/PanoFlow` folder.
208
+
209
+ Our PanShot dataset is built upon [PanFlow](https://github.com/chengzhag/PanFlow) dataset's videos and slam_poses. Please download follow their [instructions](https://github.com/chengzhag/PanFlow/tree/main/curation#download-data) on how to download the full videos and download their meta and slam_poses files following [Full Dataset](https://github.com/chengzhag/PanFlow/tree/main#-full-dataset).
210
+
211
+ Then process the dataset with:
212
+
213
+ ```bash
214
+ conda activate UCPE
215
+ python tools/filter_panflow.py
216
+
217
+ conda activate qalign
218
+ python tools/score_panflow.py
219
+
220
+ conda activate UCPE
221
+ python tools/align_panflow.py # set split = "train" and split = "test"
222
+ python tools/match_panflow.py # set split = "train" and split = "test"
223
+ python tools/normalize_panflow.py # set split = "train" and split = "test"
224
+ ```
225
+
226
+
227
+ ### PanShot
228
+
229
+ Export your YouTube cookies to `~/.config/cookies.txt` in Netscape format for 4k download. Then download and process the dataset:
230
+
231
+ ```bash
232
+ conda activate UCPE
233
+ python tools/process_panshot.py # set split = "train" and split = "test"
234
+ python tools/caption_panshot.py # set split = "train" and split = "test"
235
+ ```
236
+
237
+ </details>
238
+ <br>
239
+
240
+ ## 🏡 RealEstate10k Dataset
241
+
242
+ We use RealEstate10k Dataset for evaluation, so only poses and captions are needed. Plesae download the RealEstate10k poses from the official [website](https://google.github.io/realestate10k/) ([RealEstate10K.tgz](https://storage.cloud.google.com/realestate10k-public-files/RealEstate10K.tar.gz)) and unpack it to `data/RealEstate10k` folder. Then download the captions from [CameraCtrl](https://github.com/hehao13/CameraCtrl) ([train](https://drive.google.com/file/d/1nytBYjTa0bJ-8AMJWVCtKT2XwkJR3Jra/view) and [test](https://drive.google.com/file/d/1AGEJYbfip0jcp-ymgU9uCjUHzqETivYP/view))
243
+
244
+ The final folder structure should be like this:
245
+ ```
246
+ ├── captions
247
+ │ ├── test.json
248
+ │ └── train.json
249
+ ├── pose_files
250
+ │ ├── test
251
+ │ └── train
252
+ └── traj_normalization.txt
253
+ ```
254
+
255
+ ## 🎯 Training and Evaluation
256
+
257
+ Prepare the latent cache and train the model with:
258
+
259
+ ```bash
260
+ python src/cache.py
261
+ bash scripts/train.sh
262
+ ```
263
+
264
+ We used 8 A800 GPUs for training, which takes about 1 day. You'll get a WANDB_RUN_ID (e.g., `6wodf04s`) after starting the training. The logs will be synced to your wandb account and the checkpoints will be saved in `logs/<WANDB_RUN_ID>/checkpoints/`. You can use other commented settings in `scripts/train.sh` for ablation studies and baselines.
265
+
266
+ For evaluation, first download the pretrained model `i3d_pretrained_400.pt` in [common_metrics_on_video_quality](https://github.com/JunyaoHu/common_metrics_on_video_quality/blob/main/fvd/videogpt/i3d_pretrained_400.pt), then put it in `models/FVD` folder. Evaluate results with:
267
+
268
+ ```bash
269
+ bash scripts/evaluate.sh
270
+ ```
271
+
272
+ Please change the `WANDB_RUN_ID` in `scripts/evaluate.sh` on your own trained model and check other commented settings for ablation studies and baselines.
273
+ We note that there are some jitters in the synthesized videos due to inaccurate ViPE pose estimation. Therefore, our evaluation script uses the filtered RealEstate10k test set to avoid those cases.
274
+
275
+
276
+ ## 🔧 Tools
277
+
278
+ <details>
279
+ <summary>We also provide tools for visualizing camera trajectories, exporting figures and tables for paper, and visualizing camera statistics.</summary>
280
+
281
+ Visualize camera trajectories:
282
+
283
+ ```bash
284
+ # Export static camera trajectory visualizations
285
+ python -m tools.visualize_panshot --out_path=data/UCPE/PanShot/pose_vis-test/ --zero_first_yaw
286
+ python -m tools.visualize_re10k --pose_file_path=data/RealEstate10k/pose_files/test/ --filter_file=data/RealEstate10k/filter_files/filter_test_81.txt --relative_c2w --num_videos=150 --out_path=data/RealEstate10k/pose_vis/test/
287
+
288
+ # Export animated camera trajectory visualizations
289
+ python -m tools.visualize_panshot --out_path=data/UCPE/PanShot/pose_anim-test/ --zero_first_yaw --animate_camera
290
+ python -m tools.visualize_re10k --pose_file_path=data/RealEstate10k/pose_files/test/ --filter_file=data/RealEstate10k/filter_files/filter_test_81.txt --relative_c2w --num_videos=150 --out_path=data/RealEstate10k/pose_anim/test/ --animate_camera
291
+ ```
292
+
293
+ Export figures for paper:
294
+
295
+ ```bash
296
+ # Teaser figure
297
+ python -m tools.export_figure \
298
+ --methods \
299
+ "UCPE" "logs/6wodf04s/demo/t2v" \
300
+ --input_file \
301
+ "demo/teaser.json" \
302
+ --output_dir \
303
+ "outputs/figures/teaser" \
304
+ --animate_latup
305
+
306
+ # Try other demo configs
307
+ # "demo/pose.json" \
308
+ # "demo/lens.json" \
309
+
310
+ # Comparison on PanShot dataset
311
+ python -m tools.export_figure \
312
+ --data=PanShotDataset \
313
+ --data_root="data/UCPE" \
314
+ --methods \
315
+ "ReCamMaster" "logs/khnmur4b/predict/t2v" \
316
+ "Wan CameraCtrl" "logs/9hjx47bc/predict/t2v" \
317
+ "UCPE" "logs/6wodf04s/predict/t2v" \
318
+ --output_dir \
319
+ "outputs/figures/panshot" \
320
+ --sample_frames=3 \
321
+ --animate_latup
322
+
323
+ # Comparison on RealEstate10k dataset
324
+ python -m tools.export_figure \
325
+ --data=Re10kDataset \
326
+ --data_root="data/RealEstate10k" \
327
+ --methods \
328
+ "ReCamMaster" "logs/lg1mxf9u/RealEstate10k/t2v" \
329
+ "Wan CameraCtrl" "logs/3yf7psvi/RealEstate10k/t2v" \
330
+ "CameraCtrl" "/mnt/pfs/users/zhangchen/panshot/CameraCtrl/out/re10k" \
331
+ "AC3D" "/mnt/pfs/users/zhangchen/panshot/ac3d/out/5B/test/10000" \
332
+ "UCPE" "logs/coo9rjaq/RealEstate10k/t2v" \
333
+ --output_dir \
334
+ "outputs/figures/re10k" \
335
+ --sample_frames=3
336
+ ```
337
+
338
+ Export table for paper:
339
+
340
+ ```bash
341
+ # Comparison on PanShot (w/o Absolute Orientation Control)
342
+ python -m tools.export_table \
343
+ --pad_cols 1 \
344
+ --methods \
345
+ "ReCamMaster" "logs/lg1mxf9u/predict/evaluate_t2v/overall/last.json" \
346
+ "Wan CameraCtrl" "logs/3yf7psvi/predict/evaluate_t2v/overall/last.json" \
347
+ "UCPE" "logs/coo9rjaq/predict/evaluate_t2v/overall/last.json" \
348
+ --metrics \
349
+ "video_metrics/vfov_err" "video_metrics/k1_err" "video_metrics/k2_err" \
350
+ "video_metrics/pitch_err" "video_metrics/roll_err" \
351
+ "pose/rot_err" "pose/trans_err" "pose/cammc" \
352
+ "video_metrics/fvd" "video_metrics/fid" \
353
+ "video_metrics/cs_text"
354
+
355
+ # Comparison on PanShot (w/ Absolute Orientation Control)
356
+ python -m tools.export_table \
357
+ --pad_cols 1 \
358
+ --methods \
359
+ "ReCamMaster" "logs/khnmur4b/predict/evaluate_t2v/overall/last.json" \
360
+ "Wan CameraCtrl" "logs/9hjx47bc/predict/evaluate_t2v/overall/last.json" \
361
+ "UCPE" "logs/6wodf04s/predict/evaluate_t2v/overall/last.json" \
362
+ --metrics \
363
+ "video_metrics/vfov_err" "video_metrics/k1_err" "video_metrics/k2_err" \
364
+ "video_metrics/pitch_err" "video_metrics/roll_err" \
365
+ "pose/rot_err" "pose/trans_err" "pose/cammc" \
366
+ "video_metrics/fvd" "video_metrics/fid" \
367
+ "video_metrics/cs_text"
368
+
369
+ # Ablation Study on PanShot
370
+ python -m tools.export_table \
371
+ --pad_cols 1 \
372
+ --methods \
373
+ "1/2-dim (\$128 \times 6\$)" "logs/r0hmwcag/predict/evaluate_t2v/overall/last.json" \
374
+ "1/4-dim (\$128 \times 3\$)" "logs/nv4al3mj/predict/evaluate_t2v/overall/last.json" \
375
+ "1/8-dim (\$192 \times 1\$)" "logs/6wodf04s/predict/evaluate_t2v/overall/last.json" \
376
+ "1/12-dim (\$128 \times 1\$)" "logs/lkxh4srz/predict/evaluate_t2v/overall/last.json" \
377
+ "Pre-Attn" "logs/p03o7rqy/predict/evaluate_t2v/overall/last.json" \
378
+ "Post-Attn" "logs/82awngqn/predict/evaluate_t2v/overall/last.json" \
379
+ "PRoPE" "logs/wekc4yx6/predict/evaluate_t2v/overall/last.json" \
380
+ "GTA" "logs/z0cfx65s/predict/evaluate_t2v/overall/last.json" \
381
+ --metrics \
382
+ "video_metrics/vfov_err" "video_metrics/k1_err" "video_metrics/k2_err" \
383
+ "video_metrics/pitch_err" "video_metrics/roll_err" \
384
+ "pose/rot_err" "pose/trans_err" "pose/cammc" \
385
+ "video_metrics/fvd" "video_metrics/fid" \
386
+ "video_metrics/cs_text"
387
+
388
+ # Comparison on RealEstate10k
389
+ python -m tools.export_table \
390
+ --methods \
391
+ "ReCamMaster" "logs/lg1mxf9u/RealEstate10k/evaluate_t2v/overall/last.json" \
392
+ "Wan CameraCtrl" "logs/3yf7psvi/RealEstate10k/evaluate_t2v/overall/last.json" \
393
+ "CameraCtrl" "../CameraCtrl/out/evaluate_re10k/overall/last.json" \
394
+ "AC3D" "../ac3d/out/5B/test/evaluate_10000/overall/last.json" \
395
+ "UCPE" "logs/coo9rjaq/RealEstate10k/evaluate_t2v/overall/last.json" \
396
+ --metrics \
397
+ "pose/rot_err" "pose/trans_err" "pose/cammc" \
398
+ "qalign/image_quality" "qalign/image_aesthetic" "qalign/video_quality"
399
+ ```
400
+
401
+ Visualize camera statistics:
402
+ ```bash
403
+ # PanShot
404
+ python -m tools.dataset_statistics \
405
+ --data=PanShotDataset \
406
+ --data_root=data/UCPE \
407
+ --output_dir=outputs/suppl/panshot \
408
+ --color=C0
409
+
410
+ # RE10K
411
+ python -m tools.dataset_statistics \
412
+ --data=Re10kDataset \
413
+ --data_root=data/RealEstate10k \
414
+ --output_dir=outputs/suppl/re10k \
415
+ --color=C1
416
+ ```
417
+
418
+ </details>
419
+ <br>
420
+
421
+ ## 💡 Acknowledgements
422
+
423
+ Our paper cannot be completed without the amazing open-source projects [Wan2.1](https://github.com/Wan-Video/Wan2.1), [AC3D](https://github.com/snap-research/ac3d), [ReCamMaster](https://github.com/KlingTeam/ReCamMaster), [CameraCtrl](https://github.com/hehao13/CameraCtrl), [prope](https://github.com/liruilong940607/prope), [vllm](https://github.com/vllm-project/vllm), [stella_vslam](https://github.com/stella-cv/stella_vslam)...
424
+
425
+ Also check out our Pan-Series works [PanFlow](https://github.com/chengzhag/PanFlow), [PanFusion](https://github.com/chengzhag/PanFusion) and [PanSplat](https://github.com/chengzhag/PanSplat) towards 3D scene generation with panoramic images!
UCPE/commands.sh ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ conda activate UCPE
2
+ source scripts/set_Wan2.2-TI2V-5B.sh
3
+ export WANDB_NAME="relray_absmap_comp8_ti2v"
4
+ export WANDB_RUN_ID="ti2v_relray_absmap_comp8"
5
+ python src/main.py fit --model.camera_condition="relray_absmap" --model.attn_compress=8 --data.data_root=/tmp/data/UCPE
6
+
7
+
8
+
9
+ conda activate vipe
10
+ python thirdparty/vipe/run.py pipeline=default \
11
+ streams=raw_mp4_stream \
12
+ streams.base_path=data/UCPE/CameraBench/videos/ \
13
+ pipeline.output.path=data/UCPE/CameraBench/vipe/ \
14
+ pipeline.output.save_artifacts=true \
15
+ pipeline.post.depth_align_model=null
16
+
17
+
18
+
19
+ # === Download and prepare datasets ===
20
+ DATA_DIR="data/UCPE"
21
+ mkdir -p "$DATA_DIR/PanShot"
22
+
23
+ # Download PanShot dataset (raw 7z archives with poses, captions, meta)
24
+ hf download chengzhag/PanShot --repo-type dataset --local-dir "$DATA_DIR/PanShot-7z"
25
+
26
+ # Extract PanShot archives (pose-train/, meta-train/, etc.)
27
+ python scripts/extract_panshot_poses.py --panshot_root "$DATA_DIR/PanShot-7z"
28
+
29
+
30
+ # latent generating
31
+ conda activate UCPE
32
+ source scripts/set_Wan2.2-TI2V-5B.sh
33
+ python src/cache.py --model.model_id=Wan2.2-TI2V-5B --data.video_subdir=videos_704 --data.data_root=data/UCPE
34
+
35
+
36
+ conda activate UCPE
37
+ source scripts/set_Wan2.2-TI2V-5B.sh
38
+ export WANDB_NAME="relray_absmap_comp8_ti2v_704"
39
+ export WANDB_RUN_ID="ti2v_relray_absmap_comp8_704"
40
+
41
+ python src/main.py fit \
42
+ --model.camera_condition="relray_absmap" \
43
+ --model.attn_compress=8 \
44
+ --data.video_subdir=videos_704 \
45
+ --data.data_root=data/UCPE
UCPE/demo/lens.json ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
4
+ "x_fov": 100.0,
5
+ "xi": 0.0,
6
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
7
+ },
8
+ {
9
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
10
+ "x_fov": 120.0,
11
+ "xi": 0.0,
12
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
13
+ },
14
+ {
15
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
16
+ "x_fov": 140.0,
17
+ "xi": 0.8,
18
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
19
+ },
20
+ {
21
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
22
+ "x_fov": 160.0,
23
+ "xi": 1.5,
24
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
25
+ },
26
+ {
27
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
28
+ "x_fov": 180.0,
29
+ "xi": 2.0,
30
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
31
+ },
32
+ {
33
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
34
+ "x_fov": 200.0,
35
+ "xi": 2.3,
36
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
37
+ },
38
+ {
39
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
40
+ "x_fov": 100.0,
41
+ "xi": 0.0,
42
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
43
+ },
44
+ {
45
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
46
+ "x_fov": 120.0,
47
+ "xi": 0.0,
48
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
49
+ },
50
+ {
51
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
52
+ "x_fov": 140.0,
53
+ "xi": 0.8,
54
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
55
+ },
56
+ {
57
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
58
+ "x_fov": 160.0,
59
+ "xi": 1.5,
60
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
61
+ },
62
+ {
63
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
64
+ "x_fov": 180.0,
65
+ "xi": 2.0,
66
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
67
+ },
68
+ {
69
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
70
+ "x_fov": 200.0,
71
+ "xi": 2.3,
72
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
73
+ }
74
+ ]
UCPE/demo/pose.json ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "pose_path": "data/UCPE/PanShot/pose-test/aQVLCO-OOqM-11-1-no_rot_aug_0.npy",
4
+ "x_fov": 100.0,
5
+ "xi": 0.0,
6
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
7
+ },
8
+ {
9
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
10
+ "x_fov": 100.0,
11
+ "xi": 0.0,
12
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
13
+ },
14
+ {
15
+ "pose_path": "data/RealEstate10k/pose_files/test/5451cefde53f06f1.txt",
16
+ "x_fov": 100.0,
17
+ "xi": 0.0,
18
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
19
+ },
20
+ {
21
+ "pose_path": "data/UCPE/PanShot/pose-test/Msw3A5IPPD0-96-0-yaw_pitch_aug_0.npy",
22
+ "x_fov": 100.0,
23
+ "xi": 0.0,
24
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
25
+ },
26
+ {
27
+ "pose_path": "data/RealEstate10k/pose_files/test/ecc2b1b9cb1fa9ad.txt",
28
+ "x_fov": 100.0,
29
+ "xi": 0.0,
30
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
31
+ },
32
+ {
33
+ "pose_path": "data/UCPE/PanShot/pose-test/N99hmkAJwlQ-3-0-linear_aug_0.npy",
34
+ "x_fov": 100.0,
35
+ "xi": 0.0,
36
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
37
+ },
38
+ {
39
+ "pose_path": "data/RealEstate10k/pose_files/test/38f7ba7fd9a83069.txt",
40
+ "x_fov": 100.0,
41
+ "xi": 0.0,
42
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
43
+ },
44
+ {
45
+ "pose_path": "data/RealEstate10k/pose_files/test/20e7a3651ec30386.txt",
46
+ "x_fov": 100.0,
47
+ "xi": 0.0,
48
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
49
+ },
50
+ {
51
+ "pose_path": "data/UCPE/PanShot/pose-test/1jVPI3bNCBc-5-0-no_rot_aug_0.npy",
52
+ "x_fov": 100.0,
53
+ "xi": 0.0,
54
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
55
+ },
56
+ {
57
+ "pose_path": "data/UCPE/PanShot/pose-test/HTdKkiu771g-3-0-no_rot_aug_0.npy",
58
+ "x_fov": 100.0,
59
+ "xi": 0.0,
60
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
61
+ },
62
+ {
63
+ "pose_path": "data/UCPE/PanShot/pose-test/YUPoNo1j420-5-0-no_rot_aug_0.npy",
64
+ "x_fov": 100.0,
65
+ "xi": 0.0,
66
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
67
+ },
68
+ {
69
+ "pose_path": "data/UCPE/PanShot/pose-test/aQVLCO-OOqM-11-1-no_rot_aug_0.npy",
70
+ "x_fov": 100.0,
71
+ "xi": 0.0,
72
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
73
+ },
74
+ {
75
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
76
+ "x_fov": 100.0,
77
+ "xi": 0.0,
78
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
79
+ },
80
+ {
81
+ "pose_path": "data/RealEstate10k/pose_files/test/5451cefde53f06f1.txt",
82
+ "x_fov": 100.0,
83
+ "xi": 0.0,
84
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
85
+ },
86
+ {
87
+ "pose_path": "data/UCPE/PanShot/pose-test/Msw3A5IPPD0-96-0-yaw_pitch_aug_0.npy",
88
+ "x_fov": 100.0,
89
+ "xi": 0.0,
90
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
91
+ },
92
+ {
93
+ "pose_path": "data/RealEstate10k/pose_files/test/ecc2b1b9cb1fa9ad.txt",
94
+ "x_fov": 100.0,
95
+ "xi": 0.0,
96
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
97
+ },
98
+ {
99
+ "pose_path": "data/UCPE/PanShot/pose-test/N99hmkAJwlQ-3-0-linear_aug_0.npy",
100
+ "x_fov": 100.0,
101
+ "xi": 0.0,
102
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
103
+ },
104
+ {
105
+ "pose_path": "data/RealEstate10k/pose_files/test/38f7ba7fd9a83069.txt",
106
+ "x_fov": 100.0,
107
+ "xi": 0.0,
108
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
109
+ },
110
+ {
111
+ "pose_path": "data/RealEstate10k/pose_files/test/20e7a3651ec30386.txt",
112
+ "x_fov": 100.0,
113
+ "xi": 0.0,
114
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
115
+ },
116
+ {
117
+ "pose_path": "data/UCPE/PanShot/pose-test/1jVPI3bNCBc-5-0-no_rot_aug_0.npy",
118
+ "x_fov": 100.0,
119
+ "xi": 0.0,
120
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
121
+ },
122
+ {
123
+ "pose_path": "data/UCPE/PanShot/pose-test/HTdKkiu771g-3-0-no_rot_aug_0.npy",
124
+ "x_fov": 100.0,
125
+ "xi": 0.0,
126
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
127
+ },
128
+ {
129
+ "pose_path": "data/UCPE/PanShot/pose-test/YUPoNo1j420-5-0-no_rot_aug_0.npy",
130
+ "x_fov": 100.0,
131
+ "xi": 0.0,
132
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
133
+ }
134
+ ]
UCPE/demo/teaser.json ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "pose_path": "data/UCPE/PanShot/pose-test/aQVLCO-OOqM-11-1-no_rot_aug_0.npy",
4
+ "x_fov": 100.0,
5
+ "xi": 0.0,
6
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
7
+ },
8
+ {
9
+ "pose_path": "data/UCPE/PanShot/pose-test/aQVLCO-OOqM-11-1-no_rot_aug_0.npy",
10
+ "x_fov": 100.0,
11
+ "xi": 0.0,
12
+ "caption": "A fluffy golden dog resting on a garden stone wall surrounded by blooming flowers, gazing curiously toward the garden with its ears gently perked and fur glowing in the warm golden sunlight filtering through the leaves."
13
+ },
14
+ {
15
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
16
+ "x_fov": 100.0,
17
+ "xi": 0.0,
18
+ "caption": "A fluffy golden dog lying on a garden stone wall surrounded by blooming flowers, calmly watching the garden with gentle eyes as warm golden sunlight filters through the leaves and soft shadows move across its fur."
19
+ },
20
+ {
21
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
22
+ "x_fov": 100.0,
23
+ "xi": 0.0,
24
+ "caption": "A fluffy gray cat lounging on a garden stone wall surrounded by blooming flowers, as the camera moves in a smooth clockwise arc around it, capturing the cat’s curious gaze, swaying tail, and the warm golden sunlight filtering through the leaves."
25
+ },
26
+ {
27
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
28
+ "x_fov": 100.0,
29
+ "xi": 0.0,
30
+ "caption": "A playful gray cat leaping from a garden stone wall, its eyes focused ahead and body stretched midair as petals and dust swirl around in warm golden sunlight filtering through the leaves."
31
+ },
32
+ {
33
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
34
+ "x_fov": 100.0,
35
+ "xi": 0.0,
36
+ "caption": "A playful golden dog leaping from a garden stone path, its gaze focused ahead as petals swirl through the air and warm sunlight glows on its fur amid softly illuminated leaves."
37
+ },
38
+ {
39
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
40
+ "x_fov": 100.0,
41
+ "xi": 0.0,
42
+ "caption": "A curious orange cat resting on a sunlit wooden table beside a window, with warm afternoon light streaming across its fur and soft shadows on the polished surface, surrounded by a few open books and green houseplants that gently frame the cozy, golden-lit room."
43
+ },
44
+ {
45
+ "pose_path": "data/RealEstate10k/pose_files/test/f74139ac48f19b3c.txt",
46
+ "x_fov": 100.0,
47
+ "xi": 0.0,
48
+ "caption": "A gentle brown dog resting on a sunlit wooden floor beside a window, with warm afternoon light streaming across its fur and soft shadows stretching over the polished surface, surrounded by a few open books and green houseplants that frame the cozy, golden-lit room."
49
+ },
50
+ {
51
+ "pose_path": "data/RealEstate10k/pose_files/test/5451cefde53f06f1.txt",
52
+ "x_fov": 100.0,
53
+ "xi": 0.0,
54
+ "caption": "Aerial view ascending through a dense cityscape of tall glass skyscrapers and steel towers, with sunlight glinting off reflective windows and rooftop details gradually unfolding, revealing layered streets, terraces, and the vast geometry of the urban skyline ahead."
55
+ },
56
+ {
57
+ "pose_path": "data/UCPE/PanShot/pose-test/Msw3A5IPPD0-96-0-yaw_pitch_aug_0.npy",
58
+ "x_fov": 120.0,
59
+ "xi": 0.0,
60
+ "caption": "Aerial drone view gliding over a historic European-style town with stone buildings, terracotta rooftops, and narrow winding streets, where soft golden light reveals weathered walls, old clock towers, and quiet courtyards, conveying the timeless charm and layered history of the place."
61
+ },
62
+ {
63
+ "pose_path": "data/UCPE/PanShot/pose-test/Msw3A5IPPD0-96-0-yaw_pitch_aug_0.npy",
64
+ "x_fov": 120.0,
65
+ "xi": 0.0,
66
+ "caption": "Aerial drone view soaring above a dense green forest with tall trees of varying heights and winding trails, as a clear river weaves through the landscape reflecting sunlight between the branches, revealing the depth, texture, and gentle rhythm of the forest stretching across rolling hills."
67
+ },
68
+ {
69
+ "pose_path": "data/UCPE/PanShot/pose-test/Msw3A5IPPD0-96-0-yaw_pitch_aug_0.npy",
70
+ "x_fov": 160.0,
71
+ "xi": 1.5,
72
+ "caption": "A low aerial drone view gliding just above the treetops of a dense forest, weaving through layers of tall and short trees as a winding river glimmers between them, with sunlight flickering across leaves and water, revealing shifting textures, flowing shadows, and the serene rhythm of the landscape over rolling green hills."
73
+ },
74
+ {
75
+ "pose_path": "data/RealEstate10k/pose_files/test/ecc2b1b9cb1fa9ad.txt",
76
+ "x_fov": 160.0,
77
+ "xi": 1.5,
78
+ "caption": "A close-up ground-level view following a cat walking slowly through tall green grass, its tail swaying as it occasionally turns its head toward the camera with a curious gaze, while sunlight glimmers through the leaves and soft shadows ripple across the ground."
79
+ },
80
+ {
81
+ "pose_path": "data/RealEstate10k/pose_files/test/ecc2b1b9cb1fa9ad.txt",
82
+ "x_fov": 160.0,
83
+ "xi": 1.5,
84
+ "caption": "A close-up ground-level view following a cat walking along a narrow forest path covered with fallen leaves and dappled sunlight, its tail swaying as it glances back toward the camera, while soft beams of light filter through the trees and gentle shadows move across the mossy ground."
85
+ },
86
+ {
87
+ "pose_path": "data/RealEstate10k/pose_files/test/ecc2b1b9cb1fa9ad.txt",
88
+ "x_fov": 160.0,
89
+ "xi": 1.5,
90
+ "caption": "A close-up ground-level view following a dog walking leisurely through tall green grass, its ears gently perked and tail wagging as it occasionally glances back toward the camera with a friendly gaze, while sunlight filters through the blades and soft shadows dance across the ground."
91
+ },
92
+ {
93
+ "pose_path": "data/RealEstate10k/pose_files/test/ecc2b1b9cb1fa9ad.txt",
94
+ "x_fov": 160.0,
95
+ "xi": 1.5,
96
+ "caption": "A close-up ground-level view following a dog trotting along a sandy beach near the shoreline, its paws leaving prints in the wet sand as gentle waves roll in, sunlight glinting off the water and sea breeze rustling its fur while it occasionally looks back with a playful expression."
97
+ },
98
+ {
99
+ "pose_path": "data/UCPE/PanShot/pose-test/N99hmkAJwlQ-3-0-linear_aug_0.npy",
100
+ "x_fov": 160.0,
101
+ "xi": 1.5,
102
+ "caption": "A dramatic first-person drone view racing through a waterfall gorge where torrents crash between moss-covered cliffs and overhanging trees, with mist swirling, sunlight glinting off wet rocks and flowing water, and glimpses of ferns and boulders adding cinematic intensity."
103
+ },
104
+ {
105
+ "pose_path": "data/UCPE/PanShot/pose-test/N99hmkAJwlQ-3-0-linear_aug_0.npy",
106
+ "x_fov": 180.0,
107
+ "xi": 2.0,
108
+ "caption": "A high-speed first-person drone view diving through a narrow gorge where a powerful waterfall crashes between moss-covered rocks and tall trees cling to the cliffs, with mist swirling through the air and sunlight scattering off droplets as the roaring water fills the scene with energy and depth."
109
+ },
110
+ {
111
+ "pose_path": "data/UCPE/PanShot/pose-test/N99hmkAJwlQ-3-0-yaw_aug_0.npy",
112
+ "x_fov": 160.0,
113
+ "xi": 1.5,
114
+ "caption": "A daytime first-person dashcam view driving along a city street with moderate traffic, showing cars and trucks ahead moving between lane markings, with roadside buildings, power lines, and trees under a partly cloudy sky as soft sunlight and shadows drift across the road."
115
+ },
116
+ {
117
+ "pose_path": "data/RealEstate10k/pose_files/test/38f7ba7fd9a83069.txt",
118
+ "x_fov": 160.0,
119
+ "xi": 1.5,
120
+ "caption": "A first-person dashcam view from inside a car looking through the windshield while approaching an intersection on a sunny afternoon, smoothly turning right onto a quiet tree-lined street with parked cars and small shops, as sunlight filters through the leaves and reflections glide across the glass."
121
+ },
122
+ {
123
+ "pose_path": "data/RealEstate10k/pose_files/test/38f7ba7fd9a83069.txt",
124
+ "x_fov": 160.0,
125
+ "xi": 1.5,
126
+ "caption": "A daytime dashcam view driving along a suburban street under a partly cloudy sky, with rows of cars ahead moving slowly in traffic, white pickup trucks and sedans lining both lanes, power lines stretching overhead, and trees and low buildings framing the road in soft sunlight."
127
+ },
128
+ {
129
+ "pose_path": "data/RealEstate10k/pose_files/test/20e7a3651ec30386.txt",
130
+ "x_fov": 200.0,
131
+ "xi": 2.3,
132
+ "caption": "A first-person view moving through a large warehouse with tall shelves and stacked boxes, illuminated by cool fluorescent lights, before coming to a steady stop in front of a metal rack filled with labeled packages, as faint echoes, reflections, and mechanical ambience fill the quiet industrial space."
133
+ },
134
+ {
135
+ "pose_path": "data/UCPE/PanShot/pose-test/1jVPI3bNCBc-5-0-no_rot_aug_0.npy",
136
+ "x_fov": 200.0,
137
+ "xi": 2.3,
138
+ "caption": "A first-person view moving through a large warehouse with tall shelves and stacked boxes, illuminated by cool fluorescent lights, before coming to a steady stop in front of a metal rack filled with labeled packages, as faint echoes, reflections, and mechanical ambience fill the quiet industrial space."
139
+ },
140
+ {
141
+ "pose_path": "data/UCPE/PanShot/pose-test/HTdKkiu771g-3-0-no_rot_aug_0.npy",
142
+ "x_fov": 200.0,
143
+ "xi": 2.3,
144
+ "caption": "A time-lapse view from an airplane window at night, showing the wing silhouetted against the glowing Milky Way that slowly rotates above the horizon, while a vast sea of clouds drifts below like rolling waves, illuminated by faint starlight and distant lightning flashes that shimmer across the metallic surface of the wing."
145
+ },
146
+ {
147
+ "pose_path": "data/UCPE/PanShot/pose-test/YUPoNo1j420-5-0-no_rot_aug_0.npy",
148
+ "x_fov": 200.0,
149
+ "xi": 2.3,
150
+ "caption": "A time-lapse view from an airplane window at night, showing the wing silhouetted against the glowing Milky Way that slowly rotates above the horizon, while a vast sea of clouds drifts below like rolling waves, illuminated by faint starlight and distant lightning flashes that shimmer across the metallic surface of the wing."
151
+ },
152
+ {
153
+ "pose_path": "data/UCPE/PanShot/pose-test/HTdKkiu771g-3-0-no_rot_aug_0.npy",
154
+ "x_fov": 160.0,
155
+ "xi": 1.5,
156
+ "caption": "A time-lapse night view from the foot of a snow-covered mountain, with towering pine trees in the foreground and the majestic peak centered in the frame, as the Milky Way arches slowly across the sky, casting faint light over the snowy slopes and shimmering through the crisp, clear mountain air."
157
+ },
158
+ {
159
+ "pose_path": "data/UCPE/PanShot/pose-test/YUPoNo1j420-5-0-no_rot_aug_0.npy",
160
+ "x_fov": 160.0,
161
+ "xi": 1.5,
162
+ "caption": "A time-lapse night view from the foot of a snow-covered mountain, with towering pine trees in the foreground and the majestic peak centered in the frame, as the Milky Way arches slowly across the sky, casting faint light over the snowy slopes and shimmering through the crisp, clear mountain air."
163
+ }
164
+ ]
UCPE/diffsynth/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from .data import *
2
+ from .models import *
3
+ from .prompters import *
4
+ from .schedulers import *
5
+ from .pipelines import *
6
+ from .controlnets import *
UCPE/images/cameras.png ADDED
UCPE/images/orientation.png ADDED
UCPE/requirements.txt ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch==2.7.1
2
+ torchvision
3
+ transformers
4
+ imageio
5
+ imageio[ffmpeg]
6
+ safetensors
7
+ einops
8
+ sentencepiece
9
+ protobuf
10
+ modelscope
11
+ ftfy
12
+ pynvml
13
+ pandas
14
+ accelerate
15
+ peft
16
+ datasets
17
+ qwen-vl-utils[decord]
18
+ jsonlines
19
+ matplotlib
20
+ ffmpeg-python
21
+ vllm>0.7.2
22
+ viser
23
+ plotly
24
+ yt-dlp
25
+ pyarrow
26
+ lightning
27
+ websockets
28
+ jsonargparse[signatures]
29
+ deepspeed
30
+ wandb
31
+ omegaconf
32
+ pydantic
33
+ pydantic-settings
34
+ torchmetrics[image]
35
+ tyro
36
+ seaborn
UCPE/scripts/compare_panshot.sh ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Run UCPE inference on the PanShot test set and stitch each result with its
3
+ # ground-truth reference into a side-by-side comparison mp4.
4
+ #
5
+ # Usage:
6
+ # bash scripts/compare_panshot.sh [N]
7
+ #
8
+ # Args:
9
+ # N number of samples to generate (default: 8). Pass 0 for the full test set.
10
+ #
11
+ # Env overrides:
12
+ # WANDB_RUN_ID default: 6wodf04s
13
+ # CKPT_PATH default: logs/$WANDB_RUN_ID/checkpoints/pytorch_model.bin
14
+ # VIDEO_SUBDIR default: videos_704 (PanShot videos-test is empty on this machine,
15
+ # videos_704-test is the available variant)
16
+
17
+ set -euo pipefail
18
+
19
+ cd "$(dirname "$0")/.."
20
+
21
+ source scripts/set_Wan2.1-T2V-1.3B.sh
22
+
23
+ export WANDB_MODE=disabled
24
+ export WANDB_RUN_ID="${WANDB_RUN_ID:-6wodf04s}"
25
+
26
+ CKPT_PATH="${CKPT_PATH:-logs/${WANDB_RUN_ID}/checkpoints/pytorch_model.bin}"
27
+ VIDEO_SUBDIR="${VIDEO_SUBDIR:-videos_704}"
28
+ NUM_GPUS="${NUM_GPUS:-8}"
29
+ N="${1:-8}"
30
+
31
+ # Use the local Wan2.1-T2V-1.3B symlink (-> HF cache snapshot) instead of the
32
+ # default "Wan-AI/Wan2.1-T2V-1.3B" model_id, which would trigger a slow
33
+ # ModelScope re-download (HF_HUB_OFFLINE=1 doesn't suppress ModelScope).
34
+ if [[ -d "Wan2.1-T2V-1.3B" ]]; then
35
+ export PL_PREDICT__MODEL__MODEL_ID="Wan2.1-T2V-1.3B"
36
+ fi
37
+
38
+ if [[ ! -f "$CKPT_PATH" ]]; then
39
+ echo "ERROR: UCPE adapter not found at $CKPT_PATH" >&2
40
+ exit 1
41
+ fi
42
+
43
+ export PL_PREDICT__MODEL__CKPT_PATH="$CKPT_PATH"
44
+ # set_*.sh sets PL_PREDICT__CKPT_PATH=last (Lightning resume), but we don't have a
45
+ # last.ckpt for 6wodf04s — clear it so Lightning uses the in-memory model that
46
+ # already loaded pytorch_model.bin in PanShotTrainModule.__init__.
47
+ unset PL_PREDICT__CKPT_PATH
48
+
49
+ LIMIT_ARG=()
50
+ if [[ "$N" != "0" ]]; then
51
+ # Lightning's limit_predict_batches is per-rank under DDP, so ceil(N / NUM_GPUS)
52
+ # gives ~N total samples across all ranks.
53
+ PER_RANK=$(( (N + NUM_GPUS - 1) / NUM_GPUS ))
54
+ LIMIT_ARG=(--trainer.limit_predict_batches="$PER_RANK")
55
+ EFFECTIVE_N=$(( PER_RANK * NUM_GPUS ))
56
+ else
57
+ EFFECTIVE_N=0 # full test set
58
+ fi
59
+
60
+ # Map the requested GPU count to CUDA_VISIBLE_DEVICES if not already set.
61
+ if [[ -z "${CUDA_VISIBLE_DEVICES:-}" ]]; then
62
+ CVD=$(seq -s, 0 $((NUM_GPUS - 1)))
63
+ export CUDA_VISIBLE_DEVICES="$CVD"
64
+ fi
65
+
66
+ # DDP strategy + multi-GPU. For NUM_GPUS=1 use auto strategy (no DDP overhead).
67
+ STRATEGY_ARG=()
68
+ if [[ "$NUM_GPUS" -gt 1 ]]; then
69
+ STRATEGY_ARG=(--trainer.strategy=ddp --trainer.devices="$NUM_GPUS")
70
+ else
71
+ STRATEGY_ARG=(--trainer.devices=1)
72
+ fi
73
+
74
+ echo ">>> UCPE predict on PanShot: run=$WANDB_RUN_ID ckpt=$CKPT_PATH videos=$VIDEO_SUBDIR"
75
+ echo ">>> gpus=$NUM_GPUS (CUDA_VISIBLE_DEVICES=$CUDA_VISIBLE_DEVICES) per_rank=${PER_RANK:-all} effective_n=$EFFECTIVE_N"
76
+
77
+ # DDP teardown can SIGPROF (exit 155) on this cluster after all videos are
78
+ # already saved. Tolerate that — but re-raise any other failure.
79
+ set +e
80
+ python src/main.py predict \
81
+ --data=PanShotDataModule \
82
+ --data.video_subdir="$VIDEO_SUBDIR" \
83
+ --ckpt_path=null \
84
+ "${STRATEGY_ARG[@]}" \
85
+ "${LIMIT_ARG[@]}"
86
+ rc=$?
87
+ set -e
88
+ if [[ $rc -ne 0 && $rc -ne 155 ]]; then
89
+ echo "ERROR: predict exited $rc" >&2
90
+ exit $rc
91
+ fi
92
+ [[ $rc -eq 155 ]] && echo ">>> (predict exited 155 / SIGPROF after saving — tolerated)"
93
+
94
+ OUT_ROOT="logs/${WANDB_RUN_ID}/predict"
95
+ GEN_DIR="$OUT_ROOT/t2v"
96
+ REF_DIR="$OUT_ROOT/reference"
97
+ CAP_DIR="$OUT_ROOT/caption"
98
+ CMP_DIR="$OUT_ROOT/comparison"
99
+ mkdir -p "$CMP_DIR"
100
+
101
+ FONT="/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
102
+
103
+ echo
104
+ echo ">>> Stitching side-by-side comparisons -> $CMP_DIR"
105
+
106
+ shopt -s nullglob
107
+ count=0
108
+ skipped=0
109
+ for gen in "$GEN_DIR"/*.mp4; do
110
+ vid="$(basename "$gen" .mp4)"
111
+ ref="$REF_DIR/$vid.mp4"
112
+ out="$CMP_DIR/$vid.mp4"
113
+
114
+ if [[ ! -f "$ref" ]]; then
115
+ echo " [skip] no reference for $vid"
116
+ skipped=$((skipped+1))
117
+ continue
118
+ fi
119
+ if [[ -f "$out" ]]; then
120
+ echo " [skip] exists $vid"
121
+ skipped=$((skipped+1))
122
+ continue
123
+ fi
124
+
125
+ # Reference is at videos_704 native (1280x704); generated is at model size
126
+ # (832x480). Scale both to height=480 (preserve aspect via -2) before hstack.
127
+ ffmpeg -y -hide_banner -loglevel error \
128
+ -i "$ref" -i "$gen" \
129
+ -filter_complex "\
130
+ [0:v]scale=-2:480,setsar=1,drawtext=fontfile=$FONT:text='Ground Truth':x=12:y=12:fontsize=28:fontcolor=white:box=1:boxborderw=6:boxcolor=black@0.55[gt];\
131
+ [1:v]scale=-2:480,setsar=1,drawtext=fontfile=$FONT:text='UCPE Generated':x=12:y=12:fontsize=28:fontcolor=white:box=1:boxborderw=6:boxcolor=black@0.55[ge];\
132
+ [gt][ge]hstack=inputs=2[v]" \
133
+ -map "[v]" -c:v libx264 -pix_fmt yuv420p -crf 18 -preset fast \
134
+ "$out"
135
+
136
+ count=$((count+1))
137
+ echo " [ok] $vid"
138
+ done
139
+
140
+ echo
141
+ echo ">>> Wrote $count new comparisons (skipped $skipped)"
142
+ echo " gen: $GEN_DIR"
143
+ echo " ref: $REF_DIR"
144
+ echo " cap: $CAP_DIR (caption text per video_id)"
145
+ echo " out: $CMP_DIR"
UCPE/scripts/demo.sh ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ source scripts/set_Wan2.1-T2V-1.3B.sh
2
+ export WANDB_MODE=disabled
3
+
4
+ export WANDB_RUN_ID=6wodf04s
5
+ export PL_PREDICT__DATA="DemoDataModule"
6
+ export PL_PREDICT__MODEL__CKPT_PATH="logs/6wodf04s/checkpoints/pytorch_model.bin"
7
+ # export PL_PREDICT__MODEL__NUM_PREDICT=5 # Number of predictions to generate per input
8
+
9
+ python -m src.main predict --data.input_file="demo/lens.json"
10
+ python -m src.main predict --data.input_file="demo/pose.json"
11
+ python -m src.main predict --data.input_file="demo/teaser.json"
UCPE/scripts/evaluate.sh ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export EVAL_DATA_ROOT="data/UCPE"
2
+ export EVAL_NUM_FRAMES=81
3
+ # export EVAL_TEST_STEPS='["overall"]'
4
+ # export EVAL_LIMIT_EVAL_VIDEOS=10
5
+ export HF_HUB_OFFLINE=1
6
+
7
+
8
+ # python src/evaluate.py --evaluate_gt True
9
+ # WANDB_RUN_ID=lg1mxf9u python src/evaluate.py # recammaster_norm
10
+ # WANDB_RUN_ID=3yf7psvi python src/evaluate.py # plucker_norm
11
+ # WANDB_RUN_ID=khnmur4b python src/evaluate.py # recammaster_noyaw
12
+ # WANDB_RUN_ID=9hjx47bc python src/evaluate.py # plucker_noyaw
13
+ WANDB_RUN_ID=6wodf04s python src/evaluate.py # relray_absmap_comp8
14
+ # WANDB_RUN_ID=nv4al3mj python src/evaluate.py # relray_absmap_comp4
15
+ # WANDB_RUN_ID=lkxh4srz python src/evaluate.py # relray_absmap_comp12
16
+ # WANDB_RUN_ID=r0hmwcag python src/evaluate.py # relray_absmap_comp2
17
+ # WANDB_RUN_ID=p03o7rqy python src/evaluate.py # relray_absmap_comp8_before
18
+ # WANDB_RUN_ID=82awngqn python src/evaluate.py # relray_absmap_comp8_after
19
+ # WANDB_RUN_ID=coo9rjaq python src/evaluate.py # relray
20
+ # WANDB_RUN_ID=wekc4yx6 python src/evaluate.py # prope_absmap
21
+ # WANDB_RUN_ID=z0cfx65s python src/evaluate.py # gta_absmap
22
+
23
+ export EVAL_DATA="Re10kDataset"
24
+ export EVAL_DATA_ROOT="data/RealEstate10k"
25
+ export EVAL_POSE_FRAMES=16
26
+ export EVAL_LIMIT_EVAL_VIDEOS=100
27
+ # python src/evaluate.py --frame_stride=2 --test_res_path=/mnt/pfs/users/zhangchen/panshot/ac3d/out/5B/test/10000 # ac3d
28
+ # python src/evaluate.py --frame_stride=1 --test_res_path=/mnt/pfs/users/zhangchen/panshot/CameraCtrl/out/re10k # cameractrl
29
+ export EVAL_FRAME_STRIDE=4
30
+ # WANDB_RUN_ID=lg1mxf9u python src/evaluate.py # recammaster_norm
31
+ # WANDB_RUN_ID=3yf7psvi python src/evaluate.py # plucker_norm
32
+ # WANDB_RUN_ID=khnmur4b python src/evaluate.py # recammaster_noyaw
33
+ # WANDB_RUN_ID=9hjx47bc python src/evaluate.py # plucker_noyaw
34
+ WANDB_RUN_ID=6wodf04s python src/evaluate.py # relray_absmap_comp8
35
+ # WANDB_RUN_ID=nv4al3mj python src/evaluate.py # relray_absmap_comp4
36
+ # WANDB_RUN_ID=lkxh4srz python src/evaluate.py # relray_absmap_comp12
37
+ # WANDB_RUN_ID=r0hmwcag python src/evaluate.py # relray_absmap_comp2
38
+ # WANDB_RUN_ID=p03o7rqy python src/evaluate.py # relray_absmap_comp8_before
39
+ # WANDB_RUN_ID=82awngqn python src/evaluate.py # relray_absmap_comp8_after
40
+ # WANDB_RUN_ID=coo9rjaq python src/evaluate.py # relray
41
+ # WANDB_RUN_ID=wekc4yx6 python src/evaluate.py # prope_absmap
42
+ # WANDB_RUN_ID=z0cfx65s python src/evaluate.py # gta_absmap
UCPE/scripts/inference.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from PIL import Image
3
+ from diffsynth import save_video, VideoData, load_state_dict
4
+ from diffsynth.pipelines.wan_video_new import WanVideoPipeline, ModelConfig
5
+
6
+
7
+ prompt = "The camera smoothly arcs counterclockwise, offering a serene view of a secluded island surrounded by crystal-clear waters. The lush greenery and rugged cliffs create a picturesque contrast against the deep blue sea, while distant boats dot the horizon under a clear sky."
8
+
9
+ # pipe = WanVideoPipeline.from_pretrained(
10
+ # torch_dtype=torch.bfloat16,
11
+ # device="cuda",
12
+ # model_configs=[
13
+ # ModelConfig(model_id="Wan-AI/Wan2.1-T2V-1.3B", origin_file_pattern="diffusion_pytorch_model*.safetensors", offload_device="cpu"),
14
+ # ModelConfig(model_id="Wan-AI/Wan2.1-T2V-1.3B", origin_file_pattern="models_t5_umt5-xxl-enc-bf16.pth", offload_device="cpu"),
15
+ # ModelConfig(model_id="Wan-AI/Wan2.1-T2V-1.3B", origin_file_pattern="Wan2.1_VAE.pth", offload_device="cpu"),
16
+ # ],
17
+ # )
18
+ # pipe.enable_vram_management()
19
+
20
+ # video = pipe(
21
+ # prompt=prompt,
22
+ # negative_prompt="色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
23
+ # seed=1, tiled=True
24
+ # )
25
+ # save_video(video, "outputs/video_Wan2.1-T2V-1.3B.mp4", fps=16, quality=5)
26
+
27
+ pipe = WanVideoPipeline.from_pretrained(
28
+ torch_dtype=torch.bfloat16,
29
+ device="cuda",
30
+ model_configs=[
31
+ ModelConfig(model_id="Wan-AI/Wan2.1-T2V-1.3B", origin_file_pattern="diffusion_pytorch_model*.safetensors", offload_device="cpu"),
32
+ ModelConfig(model_id="Wan-AI/Wan2.1-T2V-1.3B", origin_file_pattern="models_t5_umt5-xxl-enc-bf16.pth", offload_device="cpu"),
33
+ ModelConfig(model_id="Wan-AI/Wan2.1-T2V-1.3B", origin_file_pattern="Wan2.1_VAE.pth", offload_device="cpu"),
34
+ ],
35
+ )
36
+ state_dict = load_state_dict("models/train/Wan2.1-T2V-1.3B_full-1gpu/epoch-2.safetensors")
37
+ pipe.dit.load_state_dict(state_dict)
38
+ pipe.enable_vram_management()
39
+
40
+ video = pipe(
41
+ prompt=prompt,
42
+ negative_prompt="色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
43
+ seed=1, tiled=True
44
+ )
45
+ save_video(video, "outputs/video_Wan2.1-T2V-1.3B_full.mp4", fps=16, quality=5)
UCPE/scripts/predict.sh ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ source scripts/set_Wan2.1-T2V-1.3B.sh
2
+ export WANDB_MODE=disabled
3
+ # export PL_PREDICT__TRAINER__LIMIT_PREDICT_BATCHES=20
4
+
5
+ # WANDB_RUN_ID=46ooy8no python src/main.py predict \
6
+ # --model.camera_condition="ucpe"
7
+
8
+ # WANDB_RUN_ID=7ur7pldr python src/main.py predict \
9
+ # --model.camera_condition="prope"
10
+
11
+ # WANDB_RUN_ID=txloxo2j python src/main.py predict \
12
+ # --model.camera_condition="plucker"
13
+
14
+ # WANDB_RUN_ID=gdlseut5 python src/main.py predict \
15
+ # --model.camera_condition="gta"
16
+
17
+ # WANDB_RUN_ID=b3rv5pk8 python src/main.py predict \
18
+ # --model.camera_condition="recammaster"
19
+
20
+ # WANDB_RUN_ID=shgomfd7 python src/main.py predict \
21
+ # --model.camera_condition="ucpe" \
22
+ # --model.attn_compress=4
UCPE/scripts/predict_one_sample.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run UCPE's WanVideoPipeline on ONE PanShot sample and write the output mp4.
2
+
3
+ Standalone (no Lightning trainer); reuses UCPE's `PanShotTrainModule.__init__` for
4
+ the pipeline + camera-patch setup, then loads the DeepSpeed checkpoint manually,
5
+ fetches a single sample by index from PanShotDataset, and calls `pipe(...)` the
6
+ same way `PanShotTrainModule.forward` does in src/main.py.
7
+
8
+ Used by ../cf_ucpe/scripts/compare_inference.py via subprocess.
9
+ """
10
+ import argparse
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import torch
16
+
17
+ # Make UCPE imports available regardless of cwd at invocation time.
18
+ HERE = Path(__file__).resolve().parent
19
+ UCPE_ROOT = HERE.parent
20
+ sys.path.insert(0, str(UCPE_ROOT))
21
+
22
+ from diffsynth import save_video # noqa: E402
23
+ from src.main import PanShotTrainModule # noqa: E402
24
+ from src.dataset import PanShotDataset # noqa: E402
25
+
26
+ NEGATIVE_PROMPT = (
27
+ "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,"
28
+ "最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,"
29
+ "畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"
30
+ )
31
+
32
+
33
+ def parse_args():
34
+ p = argparse.ArgumentParser()
35
+ p.add_argument("--sample_idx", type=int, default=None,
36
+ help="Index into the test split (after filtering). "
37
+ "Mutually exclusive with --video_id.")
38
+ p.add_argument("--video_id", default=None,
39
+ help="Pick the exact PanShot video by id (recommended for "
40
+ "cross-codebase reproducibility).")
41
+ p.add_argument("--ckpt_path", required=True,
42
+ help="Path to UCPE checkpoint. Either a .ckpt file or a "
43
+ "DeepSpeed folder (last.ckpt/checkpoint/mp_rank_00_model_states.pt).")
44
+ p.add_argument("--output_path", required=True, help="Where to write the mp4.")
45
+ p.add_argument("--data_root", default=str(UCPE_ROOT / "data" / "UCPE"),
46
+ help="UCPE data root (parent of PanShot/).")
47
+ p.add_argument("--video_subdir", default="videos_704")
48
+ p.add_argument("--model_id", default=str(UCPE_ROOT / "Wan2.2-TI2V-5B"),
49
+ help="Local Wan2.2-TI2V-5B model dir, or HF repo id.")
50
+ p.add_argument("--height", type=int, default=704)
51
+ p.add_argument("--width", type=int, default=1280)
52
+ p.add_argument("--num_frames", type=int, default=81)
53
+ p.add_argument("--num_inference_steps", type=int, default=50)
54
+ p.add_argument("--camera_condition", default="relray_absmap")
55
+ p.add_argument("--attn_compress", type=int, default=8)
56
+ p.add_argument("--adaptation_method", default="parallel",
57
+ choices=["before", "after", "parallel"])
58
+ p.add_argument("--split", default="test")
59
+ p.add_argument("--seed", type=int, default=0)
60
+ p.add_argument("--fps", type=int, default=16)
61
+ return p.parse_args()
62
+
63
+
64
+ def resolve_ckpt(path_str):
65
+ """Accept either a .pt file or a DeepSpeed folder (returns the .pt inside)."""
66
+ p = Path(path_str)
67
+ if p.is_dir():
68
+ cand = p / "checkpoint" / "mp_rank_00_model_states.pt"
69
+ if not cand.exists():
70
+ sys.exit(f"DeepSpeed folder {p} missing checkpoint/mp_rank_00_model_states.pt")
71
+ return str(cand)
72
+ return str(p)
73
+
74
+
75
+ def main():
76
+ args = parse_args()
77
+ torch.set_grad_enabled(False)
78
+
79
+ # -------- 1. Build UCPE model (pipe + camera patch) --------
80
+ # PanShotTrainModule handles: from_pretrained, patch_dit, enable_grad. ckpt_path=None
81
+ # because we load it manually below to support DeepSpeed format.
82
+ print(f"[predict_one_sample] building model (model_id={args.model_id})")
83
+ model = PanShotTrainModule(
84
+ model_id=args.model_id,
85
+ ckpt_path=None,
86
+ height=args.height,
87
+ width=args.width,
88
+ num_frames=args.num_frames,
89
+ num_inference_steps=args.num_inference_steps,
90
+ camera_condition=args.camera_condition,
91
+ attn_compress=args.attn_compress,
92
+ adaptation_method=args.adaptation_method,
93
+ )
94
+ model = model.to("cuda")
95
+ model.pipe.device = torch.device("cuda")
96
+ model.eval()
97
+
98
+ # -------- 2. Load checkpoint (DeepSpeed or Lightning .ckpt) --------
99
+ ckpt_file = resolve_ckpt(args.ckpt_path)
100
+ print(f"[predict_one_sample] loading ckpt: {ckpt_file}")
101
+ sd = torch.load(ckpt_file, map_location="cpu", weights_only=False)
102
+ if "module" in sd: # DeepSpeed
103
+ sd = sd["module"]
104
+ elif "state_dict" in sd: # Lightning
105
+ sd = sd["state_dict"]
106
+ missing, unexpected = model.load_state_dict(sd, strict=False)
107
+ print(f"[predict_one_sample] loaded; missing={len(missing)} unexpected={len(unexpected)}")
108
+ if unexpected:
109
+ print(f"[predict_one_sample] (first unexpected) {unexpected[0]}")
110
+
111
+ # patch_dit adds cam_self_attn modules in default fp32; pipe.from_pretrained
112
+ # already loaded the rest of the DiT in bf16. Mixed dtypes blow up at
113
+ # bf16-input × fp32-weight matmuls. Cast the whole DiT to bf16 after load
114
+ # so cam modules align with the rest.
115
+ model.pipe.dit = model.pipe.dit.to(torch.bfloat16)
116
+
117
+ # -------- 3. Build a small DataModule-equivalent args object --------
118
+ # PanShotDataset uses both attribute access (args.data_root) AND
119
+ # membership tests (`"model_id" in args`), so we need a Namespace-with-
120
+ # __contains__. omegaconf.DictConfig satisfies both.
121
+ from omegaconf import DictConfig
122
+ hp = DictConfig({
123
+ "data_root": args.data_root,
124
+ "video_subdir": args.video_subdir,
125
+ "zero_first_yaw": True,
126
+ })
127
+
128
+ if (args.video_id is None) == (args.sample_idx is None):
129
+ sys.exit("specify exactly one of --video_id or --sample_idx")
130
+ dataset_kwargs = dict(load_keys=["video", "pose", "input_image"])
131
+ if args.video_id is not None:
132
+ dataset_kwargs["video_ids"] = [args.video_id]
133
+ dataset = PanShotDataset(hp, split=args.split, **dataset_kwargs)
134
+ if args.video_id is not None:
135
+ if len(dataset) == 0 or dataset.metas[0]["video_id"] != args.video_id:
136
+ sys.exit(f"video_id {args.video_id!r} not found in {args.split} split")
137
+ idx = 0
138
+ else:
139
+ if not (0 <= args.sample_idx < len(dataset)):
140
+ sys.exit(f"sample_idx {args.sample_idx} out of range [0, {len(dataset)})")
141
+ idx = args.sample_idx
142
+ sample = dataset[idx]
143
+ video_id = sample["video_id"]
144
+ print(f"[predict_one_sample] picked video_id={video_id}")
145
+
146
+ # -------- 4. Build batch (single-element 'collated') --------
147
+ # The pipe was built with torch_dtype=bf16, so float tensors must be bf16
148
+ # to match the VAE / DiT / camera-encoder Linear weights. (Lightning's
149
+ # precision machinery handles this in normal training; we have to do it
150
+ # manually here.)
151
+ def _to_batch(v, cast_float=True):
152
+ import numpy as np
153
+ if isinstance(v, np.ndarray):
154
+ v = torch.from_numpy(v)
155
+ if isinstance(v, torch.Tensor):
156
+ t = v.unsqueeze(0).to("cuda")
157
+ if cast_float and t.is_floating_point():
158
+ t = t.to(dtype=torch.bfloat16)
159
+ return t
160
+ return [v]
161
+
162
+ batch = {
163
+ "caption": [sample["caption"]],
164
+ "input_image": _to_batch(sample["input_image"]),
165
+ "pose": _to_batch(sample["pose"]),
166
+ "xi": _to_batch(torch.tensor(float(sample["xi"]))),
167
+ "x_fov": _to_batch(torch.tensor(float(sample["x_fov"]))),
168
+ }
169
+
170
+ # -------- 5. Run inference (mirrors PanShotTrainModule.forward) --------
171
+ print(f"[predict_one_sample] running pipe ({args.num_inference_steps} steps)")
172
+ video = model.pipe(
173
+ prompt=batch["caption"][0],
174
+ input_image=batch.get("input_image", None),
175
+ camera_control_panshot={k: batch[k] for k in ["pose", "xi", "x_fov"]},
176
+ negative_prompt=NEGATIVE_PROMPT,
177
+ num_inference_steps=args.num_inference_steps,
178
+ tiled=False,
179
+ seed=args.seed,
180
+ height=args.height,
181
+ width=args.width,
182
+ num_frames=args.num_frames,
183
+ )
184
+
185
+ out_path = Path(args.output_path)
186
+ out_path.parent.mkdir(parents=True, exist_ok=True)
187
+ save_video(video, str(out_path), fps=args.fps, quality=8)
188
+ print(f"[predict_one_sample] wrote: {out_path}")
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
UCPE/scripts/set_Wan2.1-T2V-1.3B.sh ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ for STAGE in FIT VALIDATE TEST PREDICT; do
2
+ export PL_${STAGE}__DATA="PanShotDataModule"
3
+ export PL_${STAGE}__CKPT_PATH="last"
4
+ export PL_${STAGE}__DATA__DATA_ROOT="data/UCPE"
5
+ export PL_${STAGE}__MODEL__FPS=16
6
+ export PL_${STAGE}__MODEL__HEIGHT=480
7
+ export PL_${STAGE}__MODEL__WIDTH=832
8
+ export PL_${STAGE}__MODEL__NUM_FRAMES=81
9
+ export PL_${STAGE}__MODEL__MODEL_ID="Wan-AI/Wan2.1-T2V-1.3B"
10
+ done
11
+
12
+ export HF_HUB_OFFLINE=1
UCPE/scripts/set_Wan2.2-TI2V-5B.sh ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ for STAGE in FIT VALIDATE TEST PREDICT; do
2
+ export PL_${STAGE}__DATA="PanShotDataModule"
3
+ export PL_${STAGE}__CKPT_PATH="last"
4
+ export PL_${STAGE}__DATA__DATA_ROOT="data/UCPE"
5
+ export PL_${STAGE}__DATA__VIDEO_SUBDIR="videos_704"
6
+ export PL_${STAGE}__MODEL__FPS=16
7
+ export PL_${STAGE}__MODEL__HEIGHT=704
8
+ export PL_${STAGE}__MODEL__WIDTH=1280
9
+ export PL_${STAGE}__MODEL__NUM_FRAMES=81
10
+ export PL_${STAGE}__MODEL__MODEL_ID="Wan2.2-TI2V-5B"
11
+ done
12
+
13
+ export HF_HUB_OFFLINE=1
UCPE/scripts/train.sh ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ source scripts/set_Wan2.1-T2V-1.3B.sh
2
+
3
+ # export WANDB_NAME="plucker_noyaw"
4
+ # python src/main.py fit --model.camera_condition="plucker" --model.learning_rate=1e-5
5
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="plucker"
6
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="plucker" --data=Re10kDataModule --trainer.limit_predict_batches=13
7
+
8
+ # export WANDB_NAME="recammaster_noyaw"
9
+ # python src/main.py fit --model.camera_condition="recammaster"
10
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="recammaster"
11
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="recammaster" --data=Re10kDataModule --trainer.limit_predict_batches=13
12
+
13
+ # export WANDB_NAME="plucker_norm"
14
+ # python src/main.py fit --model.camera_condition="plucker" --model.learning_rate=1e-5 --data.zero_first_yaw=False
15
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="plucker" --data.zero_first_yaw=False
16
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="plucker" --data=Re10kDataModule --trainer.limit_predict_batches=13
17
+
18
+ # export WANDB_NAME="recammaster_norm"
19
+ # python src/main.py fit --model.camera_condition="recammaster" --data.zero_first_yaw=False
20
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="recammaster" --data.zero_first_yaw=False
21
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="recammaster" --data=Re10kDataModule --trainer.limit_predict_batches=13
22
+
23
+ export WANDB_NAME="relray_absmap_comp8"
24
+ python src/main.py fit --model.camera_condition="relray_absmap" --model.attn_compress=8
25
+ WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.attn_compress=8
26
+ WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.attn_compress=8 --data=Re10kDataModule --trainer.limit_predict_batches=13
27
+
28
+ # export WANDB_NAME="relray_absmap_comp4"
29
+ # python src/main.py fit --model.camera_condition="relray_absmap" --model.attn_compress=4
30
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.attn_compress=4
31
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.attn_compress=4 --data=Re10kDataModule --trainer.limit_predict_batches=13
32
+
33
+ # export WANDB_NAME="relray_absmap_comp12"
34
+ # python src/main.py fit --model.camera_condition="relray_absmap" --model.attn_compress=12
35
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.attn_compress=12
36
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.attn_compress=12 --data=Re10kDataModule --trainer.limit_predict_batches=13
37
+
38
+ # export WANDB_NAME="relray_absmap_comp2"
39
+ # python src/main.py fit --model.camera_condition="relray_absmap" --model.attn_compress=2
40
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.attn_compress=2
41
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.attn_compress=2 --data=Re10kDataModule --trainer.limit_predict_batches=13
42
+
43
+ # export WANDB_NAME="relray_absmap_comp8_before"
44
+ # python src/main.py fit --model.camera_condition="relray_absmap" --model.adaptation_method="before" --model.attn_compress=8
45
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.adaptation_method="before" --model.attn_compress=8
46
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.adaptation_method="before" --model.attn_compress=8 --data=Re10kDataModule --trainer.limit_predict_batches=13
47
+
48
+ # export WANDB_NAME="relray_absmap_comp8_after"
49
+ # python src/main.py fit --model.camera_condition="relray_absmap" --model.adaptation_method="after" --model.attn_compress=8
50
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.adaptation_method="after" --model.attn_compress=8
51
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray_absmap" --model.adaptation_method="after" --model.attn_compress=8 --data=Re10kDataModule --trainer.limit_predict_batches=13
52
+
53
+ # export WANDB_NAME="relray"
54
+ # python src/main.py fit --model.camera_condition="relray" --model.attn_compress=8
55
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray" --model.attn_compress=8
56
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="relray" --model.attn_compress=8 --data=Re10kDataModule --trainer.limit_predict_batches=13
57
+
58
+ # export WANDB_NAME="prope_absmap"
59
+ # python src/main.py fit --model.camera_condition="prope_absmap" --model.attn_compress=8
60
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="prope_absmap" --model.attn_compress=8
61
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="prope_absmap" --model.attn_compress=8 --data=Re10kDataModule --trainer.limit_predict_batches=13
62
+
63
+ # export WANDB_NAME="gta_absmap"
64
+ # python src/main.py fit --model.camera_condition="gta_absmap" --model.attn_compress=8
65
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="gta_absmap" --model.attn_compress=8
66
+ # WANDB_MODE=offline python src/main.py predict --model.camera_condition="gta_absmap" --model.attn_compress=8 --data=Re10kDataModule --trainer.limit_predict_batches=13
UCPE/scripts/upload_704.sh ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ export PATH=~/miniconda3/envs/UCPE/bin:$PATH
3
+ export HF_TOKEN=$(cat /mnt/bn/foundation-ads3/weijie.lyu/UCPE/hf_token.txt)
4
+ export HF_HUB_OFFLINE=0
5
+ cd /mnt/bn/foundation-ads3/weijie.lyu/UCPE
6
+
7
+ # Stage dir with hardlinks mirroring the repo layout (train/, test/).
8
+ # upload_large_folder does not follow symlinks, so we use hardlinks instead.
9
+ # Must live on the same filesystem as the source data.
10
+ STAGE_DIR="upload_stage_704"
11
+ mkdir -p "$STAGE_DIR/train" "$STAGE_DIR/test"
12
+
13
+ while true; do
14
+ echo "$(date): Refreshing hardlinks..."
15
+ # cp -al: archive + hardlinks; -n: no-clobber (skip existing)
16
+ cp -aln data/UCPE/PanShot/videos_704-train/. "$STAGE_DIR/train/" 2>/dev/null
17
+ cp -aln data/UCPE/PanShot/videos_704-test/. "$STAGE_DIR/test/" 2>/dev/null
18
+
19
+ TRAIN_COUNT=$(ls "$STAGE_DIR/train/" 2>/dev/null | wc -l)
20
+ TEST_COUNT=$(ls "$STAGE_DIR/test/" 2>/dev/null | wc -l)
21
+ echo "$(date): Starting upload... (train: $TRAIN_COUNT, test: $TEST_COUNT)"
22
+
23
+ python -c "
24
+ from huggingface_hub import HfApi
25
+ api = HfApi()
26
+ api.upload_large_folder(
27
+ folder_path='$STAGE_DIR',
28
+ repo_id='wlyu/ucpe_videos_704',
29
+ repo_type='dataset',
30
+ )
31
+ print('Upload complete')
32
+ "
33
+
34
+ echo "$(date): Upload done. Sleeping 30 minutes..."
35
+ sleep 1800
36
+ done
UCPE/setup.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from setuptools import setup, find_packages
3
+ import pkg_resources
4
+
5
+ # Path to the requirements file
6
+ requirements_path = os.path.join(os.path.dirname(__file__), "requirements.txt")
7
+
8
+ # Read the requirements from the requirements file
9
+ if os.path.exists(requirements_path):
10
+ with open(requirements_path, 'r') as f:
11
+ install_requires = [str(r) for r in pkg_resources.parse_requirements(f)]
12
+ else:
13
+ install_requires = []
14
+
15
+ setup(
16
+ name="diffsynth",
17
+ version="1.1.8",
18
+ description="Enjoy the magic of Diffusion models!",
19
+ author="Artiprocher",
20
+ packages=find_packages(),
21
+ install_requires=install_requires,
22
+ include_package_data=True,
23
+ classifiers=[
24
+ "Programming Language :: Python :: 3",
25
+ "License :: OSI Approved :: Apache Software License",
26
+ "Operating System :: OS Independent",
27
+ ],
28
+ package_data={"diffsynth": ["tokenizer_configs/**/**/*.*"]},
29
+ python_requires='>=3.6',
30
+ )
UCPE/src/cache.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import lightning as pl
2
+ from lightning.pytorch.cli import LightningCLI
3
+ from torch.utils.data import DataLoader, Dataset
4
+ from pathlib import Path
5
+ import torch
6
+ import lightning as pl
7
+ from diffsynth.pipelines.wan_video_panshot import WanVideoPipeline, ModelConfig
8
+ from types import SimpleNamespace
9
+ from src.dataset import PanShotDataset
10
+
11
+
12
+
13
+ class PanShotDataModule(pl.LightningDataModule):
14
+ def __init__(
15
+ self,
16
+ data_root: Path = Path("data/UCPE"),
17
+ batch_size: int = 1,
18
+ num_workers: int = 4,
19
+ ):
20
+ super().__init__()
21
+ self.save_hyperparameters()
22
+
23
+ def setup(self, stage):
24
+ self.hparams.model_id = self.trainer.model.hparams.model_id
25
+ load_keys = ["video"]
26
+ self.dataset = PanShotDataset(self.hparams, split="train", load_keys=load_keys, skip_cached=True)
27
+
28
+ def test_dataloader(self):
29
+ return DataLoader(self.dataset, batch_size=self.hparams.batch_size, shuffle=False, num_workers=self.hparams.num_workers)
30
+
31
+
32
+ class PanShotCacheModule(pl.LightningModule):
33
+ def __init__(
34
+ self,
35
+ model_id: str = "Wan-AI/Wan2.1-T2V-1.3B",
36
+ ):
37
+ super().__init__()
38
+ model_configs=[
39
+ ModelConfig(model_id=model_id, origin_file_pattern="models_t5_umt5-xxl-enc-bf16.pth"),
40
+ ModelConfig(
41
+ model_id=model_id,
42
+ origin_file_pattern="Wan2.1_VAE.pth"
43
+ ),
44
+ ]
45
+ self.pipe = WanVideoPipeline.from_pretrained(
46
+ torch_dtype=torch.bfloat16,
47
+ device="cpu",
48
+ model_configs=model_configs,
49
+ )
50
+ self.pipe.dit = SimpleNamespace(
51
+ require_vae_embedding=True,
52
+ require_clip_embedding=True,
53
+ fuse_vae_embedding_in_latents=False,
54
+ )
55
+ self.pipe.scheduler.set_timesteps(1000, training=True)
56
+ self.save_hyperparameters()
57
+
58
+ def test_step(self, batch, batch_idx):
59
+ text, video, video_id = batch["caption"][0], batch["video"], batch["video_id"][0]
60
+ self.pipe.device = self.device
61
+ pth_path = self.trainer.datamodule.dataset.cache_folder / f"{video_id}.pth"
62
+ if pth_path.exists():
63
+ return
64
+ pth_path.parent.mkdir(parents=True, exist_ok=True)
65
+ _, _, num_frames, height, width = video.shape
66
+ inputs_posi = {"prompt": text}
67
+ inputs_nega = {}
68
+ inputs_shared = {
69
+ "input_video": video,
70
+ "input_image": batch.get("input_image", None),
71
+ "height": height,
72
+ "width": width,
73
+ "num_frames": num_frames,
74
+ "cfg_scale": 1,
75
+ "tiled": False,
76
+ "rand_device": None,
77
+ "use_gradient_checkpointing": False,
78
+ "use_gradient_checkpointing_offload": False,
79
+ "cfg_merge": False,
80
+ }
81
+ for unit in self.pipe.units:
82
+ inputs_shared, inputs_posi, inputs_nega = self.pipe.unit_runner(unit, self.pipe, inputs_shared, inputs_posi, inputs_nega)
83
+ inputs = {**inputs_shared, **inputs_posi}
84
+ data = {k: inputs[k][0] for k in ["input_latents", "context", "first_frame_latents"] if k in inputs}
85
+ torch.save(data, pth_path)
86
+
87
+
88
+ def main():
89
+ cli = LightningCLI(
90
+ model_class=PanShotCacheModule,
91
+ datamodule_class=PanShotDataModule,
92
+ seed_everything_default=42,
93
+ run=False,
94
+ trainer_defaults={
95
+ "precision": "bf16-true",
96
+ "logger": False,
97
+ },
98
+ save_config_callback=None,
99
+ )
100
+ trainer = cli.trainer
101
+ model = cli.model
102
+ datamodule = cli.datamodule
103
+
104
+ trainer.test(model, datamodule=datamodule)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
UCPE/src/camera_control.py ADDED
@@ -0,0 +1,678 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+ from thirdparty.prope.torch import PropeDotProductAttention
4
+ from diffsynth.models.wan_video_dit import flash_attention
5
+ from einops import rearrange, repeat, einsum
6
+ import torch.nn.functional as F
7
+ from typing import Tuple
8
+ import numpy as np
9
+ import os
10
+
11
+
12
+ def patch_dit(pipe, method, height, width, attn_compress=1, adaptation_method="parallel"):
13
+ keywords = []
14
+ if method.startswith("recam"):
15
+ if method == "recammaster":
16
+ emb_dim = 14
17
+ elif method == "recam_plucker":
18
+ emb_dim = 6
19
+ else:
20
+ raise ValueError(f"Unknown method: {method}")
21
+
22
+ dim = pipe.dit.blocks[0].self_attn.q.weight.shape[0]
23
+ for block in pipe.dit.blocks:
24
+ block.cam_encoder = nn.Linear(emb_dim, dim)
25
+ block.projector = nn.Linear(dim, dim)
26
+ block.cam_encoder.weight.data.zero_()
27
+ block.cam_encoder.bias.data.zero_()
28
+ block.projector.weight = nn.Parameter(torch.eye(dim))
29
+ block.projector.bias = nn.Parameter(torch.zeros(dim))
30
+ keywords.extend(["cam_encoder", "projector", "self_attn"])
31
+
32
+ if method == "plucker":
33
+ from diffsynth.models.wan_video_camera_controller import SimpleAdapter
34
+ pipe.dit.control_adapter = SimpleAdapter(
35
+ 24,
36
+ pipe.dit.dim,
37
+ kernel_size=[2, 2],
38
+ stride=[2, 2],
39
+ downscale_factor=pipe.vae.upsampling_factor,
40
+ )
41
+ pipe.dit.control_adapter.conv.weight.data.zero_()
42
+ pipe.dit.control_adapter.conv.bias.data.zero_()
43
+ for block in pipe.dit.control_adapter.residual_blocks:
44
+ block.conv2.weight.data.zero_()
45
+ block.conv2.bias.data.zero_()
46
+ keywords = "*"
47
+ elif any(k in method for k in ("gta", "prope", "relray")):
48
+ patch_factor = pipe.vae.upsampling_factor * 2
49
+ patches_x = width // patch_factor
50
+ patches_y = height // patch_factor
51
+
52
+ if "abs" in method:
53
+ if "absc2w" in method or "absray" in method:
54
+ emb_dim = 12
55
+ elif "absmap" in method:
56
+ emb_dim = 3
57
+ else:
58
+ raise ValueError(f"Unknown method: {method}")
59
+ else:
60
+ emb_dim = None
61
+
62
+ for block in pipe.dit.blocks:
63
+ block.cam_self_attn = UcpeSelfAttention(
64
+ pipe.dit.dim,
65
+ pipe.dit.dim // attn_compress,
66
+ block.num_heads // attn_compress,
67
+ patches_x=patches_x,
68
+ patches_y=patches_y,
69
+ image_width=width,
70
+ image_height=height,
71
+ emb_dim=emb_dim,
72
+ adaptation_method=adaptation_method,
73
+ )
74
+ keywords.append("cam_self_attn")
75
+
76
+ pipe.dit.camera_condition = method
77
+ return keywords
78
+
79
+
80
+ def enable_grad(pipe, keywords):
81
+ pipe.eval()
82
+ pipe.requires_grad_(False)
83
+ if keywords == "*":
84
+ pipe.dit.train()
85
+ pipe.dit.requires_grad_(True)
86
+ else:
87
+ for name, module in pipe.dit.named_modules():
88
+ if any(keyword in name for keyword in keywords):
89
+ print(f"Trainable: {name}")
90
+ module.train()
91
+ module.requires_grad_(True)
92
+
93
+ trainable_params = 0
94
+ seen_params = set()
95
+ for name, module in pipe.dit.named_modules():
96
+ for param in module.parameters():
97
+ if param.requires_grad and param not in seen_params:
98
+ trainable_params += param.numel()
99
+ seen_params.add(param)
100
+ print(f"Total number of trainable parameters: {trainable_params}")
101
+
102
+
103
+ def compute_fx_from_fov_xi(
104
+ x_fov: torch.Tensor | float,
105
+ xi: torch.Tensor | float,
106
+ width: int,
107
+ device: torch.device | str = "cpu",
108
+ dtype: torch.dtype = torch.float32,
109
+ ) -> torch.Tensor:
110
+ """
111
+ 根据水平视场角 (x_fov) 和 UCM 参数 (xi) 计算相机焦距 fx。
112
+
113
+ Args:
114
+ x_fov: float 或 [B] Tensor,水平视场角(单位:度)
115
+ xi: float 或 [B] Tensor,UCM 镜面参数
116
+ width: 图像宽度(像素)
117
+ device: torch.device
118
+ dtype: torch.dtype
119
+
120
+ Returns:
121
+ fx: [B] Tensor,焦距(像素单位)
122
+ """
123
+ # --- 转为 Tensor ---
124
+ def to_tensor_1d(x):
125
+ if torch.is_tensor(x):
126
+ return x.to(device=device, dtype=dtype)
127
+ return torch.tensor([x], dtype=dtype, device=device)
128
+
129
+ x_fov = to_tensor_1d(x_fov)
130
+ xi = to_tensor_1d(xi)
131
+
132
+ # --- 自动广播 ---
133
+ B = max(x_fov.shape[0], xi.shape[0])
134
+ x_fov = x_fov.view(-1).expand(B)
135
+ xi = xi.view(-1).expand(B)
136
+
137
+ # --- 计算 fx ---
138
+ theta = torch.deg2rad(0.5 * x_fov)
139
+ eps = torch.finfo(dtype).eps
140
+ denom = torch.sin(theta).clamp_min(eps)
141
+ fx = (width * 0.5) * (torch.cos(theta) + xi) / denom
142
+ return fx
143
+
144
+
145
+ def compute_fov_from_fx_xi(
146
+ fx: torch.Tensor | float,
147
+ xi: torch.Tensor | float,
148
+ width: int,
149
+ device="cpu",
150
+ dtype=torch.float32,
151
+ ):
152
+ """
153
+ 根据 UCM 模型参数 fx, xi 计算水平 FOV(度)
154
+
155
+ Args:
156
+ fx: float 或 [B] Tensor, 焦距
157
+ xi: float 或 [B] Tensor, UCM xi 参数
158
+ width: 图像宽度
159
+ Returns:
160
+ x_fov: [B], 单位 degree
161
+ """
162
+ def to_tensor_1d(x):
163
+ if torch.is_tensor(x):
164
+ return x.to(device=device, dtype=dtype)
165
+ return torch.tensor([x], dtype=dtype, device=device)
166
+
167
+ fx = to_tensor_1d(fx).view(-1)
168
+ xi = to_tensor_1d(xi).view(-1)
169
+ B = max(fx.shape[0], xi.shape[0])
170
+ fx = fx.expand(B)
171
+ xi = xi.expand(B)
172
+
173
+ # A = 2 fx / W
174
+ A = 2.0 * fx / width
175
+
176
+ # phi = arctan(1/A)
177
+ phi = torch.atan(1.0 / A)
178
+
179
+ # sin(theta - phi) = xi / sqrt(A^2 + 1)
180
+ denom = torch.sqrt(A * A + 1.0)
181
+ ratio = (xi / denom).clamp(-1.0, 1.0)
182
+ theta = torch.asin(ratio) + phi
183
+
184
+ # x_fov = 2 * theta (rad → deg)
185
+ x_fov = torch.rad2deg(2.0 * theta)
186
+ return x_fov
187
+
188
+
189
+ def ucm_unproject_grid_fov(
190
+ x_fov: float | torch.Tensor,
191
+ xi: float | torch.Tensor,
192
+ height: int,
193
+ width: int,
194
+ device: torch.device | str = "cpu",
195
+ dtype: torch.dtype = torch.float32,
196
+ ) -> torch.Tensor:
197
+ """
198
+ 计算每个样本的相机方向向量 (UCM model, 用视场角定义)。
199
+ 支持 float 或 [B] Tensor 的混合输入。
200
+ - 若全为 float → 返回 [H, W, 3]
201
+ - 若任意为 [B] → 返回 [B, H, W, 3]
202
+ """
203
+ # --- 判断是否 batched ---
204
+ is_batched = any(torch.is_tensor(p) and p.ndim == 1 for p in [x_fov, xi])
205
+
206
+ # --- 计算 fx, fy ---
207
+ fx = compute_fx_from_fov_xi(x_fov, xi, width, device, dtype)
208
+ fy = fx
209
+
210
+ # --- 调用 ucm_unproject_grid ---
211
+ from equilib.equi2pers.torch import ucm_unproject_grid
212
+ d_cam = ucm_unproject_grid(
213
+ height=height,
214
+ width=width,
215
+ fx=fx,
216
+ fy=fy,
217
+ cx=width / 2,
218
+ cy=height / 2,
219
+ xi=xi if torch.is_tensor(xi) else torch.tensor([xi], dtype=dtype, device=device),
220
+ dtype=dtype,
221
+ device=device,
222
+ y_down=True,
223
+ )
224
+
225
+ # --- 输出 shape 控制 ---
226
+ if not is_batched:
227
+ d_cam = d_cam[0] # [H, W, 3]
228
+
229
+ return d_cam
230
+
231
+
232
+ def project_ucm_points_fov(X, Y, Z, x_fov, xi, height, width):
233
+ """
234
+ Project 3D points in camera frame to UCM image plane using fov-based intrinsics.
235
+
236
+ Args:
237
+ X, Y, Z: torch.Tensor [..., 3D coordinates in camera frame]
238
+ x_fov: float or [B] —— horizontal field of view in degrees
239
+ xi: float or [B] —— UCM mirror parameter
240
+ height, width: target image dimensions
241
+
242
+ Returns:
243
+ du, dv: projected pixel coordinates [..., 2]
244
+ """
245
+ fx = compute_fx_from_fov_xi(x_fov, xi, width, X.device, X.dtype)
246
+ fy = fx
247
+ cx = width / 2
248
+ cy = height / 2
249
+
250
+ return project_ucm_points(X, Y, Z, fx, fy, cx, cy, xi)
251
+
252
+
253
+ def project_ucm_points(X, Y, Z, fx, fy, cx, cy, xi):
254
+ """
255
+ Project 3D points in camera frame to UCM image plane.
256
+
257
+ Args:
258
+ X, Y, Z: torch.Tensor [..., 3D coordinates in camera frame]
259
+ fx, fy, cx, cy: intrinsics (scalars or tensors)
260
+ xi: UCM mirror parameter
261
+
262
+ Returns:
263
+ du, dv: projected pixel coordinates [..., 2]
264
+ """
265
+ r = torch.sqrt(X * X + Y * Y + Z * Z)
266
+ alpha = Z + xi * r
267
+ du = fx * (X / alpha) + cx
268
+ dv = fy * (Y / alpha) + cy
269
+ return du, dv
270
+
271
+
272
+ def ray_condition_ucm(
273
+ x_fov, # float or [B] —— same fov as used in equi2pers
274
+ xi, # float or [B] —— same xi as used in equi2pers
275
+ pose, # [B, V, 4, 4]
276
+ height, width, # target height, width
277
+ device,
278
+ ):
279
+ """
280
+ ✅ UCM-based Plücker embedding, output format: [B, V, H, W, 6]
281
+ 🔁 Internally uses your ucm_unproject_grid() for consistent ray geometry.
282
+
283
+ Only required params:
284
+ fov_x (degree)
285
+ xi
286
+ c2w (camera-to-world pose, same as your exported pose)
287
+ H, W (spatial resolution)
288
+ device
289
+ """
290
+
291
+ d_cam = ucm_unproject_grid_fov(
292
+ x_fov, xi, height, width, device, dtype=pose.dtype
293
+ )
294
+ d_cam = repeat(d_cam, "b ... -> b v ...", v=pose.shape[1]) # [B, V, H, W, 3]
295
+ mask = d_cam.isnan().any(-1)
296
+
297
+ # --- 4. Transform rays into world coordinates using c2w ---
298
+ R = pose[..., :3, :3] # [B, V, 3, 3]
299
+ t = pose[..., :3, 3] # [B, V, 3]
300
+
301
+ d_world = torch.einsum("bvij,bvhwj->bvhwi", R, d_cam) # [B,V,H,W,3]
302
+ rays_o = t[..., None, None, :].expand_as(d_world) # [B,V,H,W,3]
303
+
304
+ # --- 5. Plücker coordinates: m = o × d ---
305
+ m = torch.cross(rays_o, d_world, dim=-1) # [B,V,H,W,3]
306
+
307
+ # --- 6. Final concat: [m, d] → [B,V,H,W,6]
308
+ plucker = torch.cat([m, d_world], dim=-1)
309
+ plucker[mask] = 0.
310
+ return plucker
311
+
312
+
313
+ def d_cam_to_angles(d_cam: torch.Tensor) -> torch.Tensor:
314
+ """
315
+ 将方向向量 [x, y, z] 转换为 [azimuth, elevation]。
316
+ 坐标系:z前,x右,y下(符合 UCM 投影输出)
317
+
318
+ 输入: d_cam: [B, H, W, 3]
319
+ 输出: angles: [B, H, W, 2] — azimuth, elevation (单位: 弧度)
320
+ """
321
+ d_unit = F.normalize(d_cam, dim=-1) # [B, H, W, 3]
322
+
323
+ x = d_unit[..., 0] # right
324
+ y = d_unit[..., 1] # down
325
+ z = d_unit[..., 2] # forward
326
+
327
+ # yaw / azimuth: angle in xz-plane
328
+ azimuth = torch.atan2(x, z) # ∈ [-π, π]
329
+
330
+ # pitch / elevation: angle above xz-plane
331
+ elevation = -torch.asin(y) # y 向下 → elevation = -asin(y)
332
+
333
+ return torch.stack([azimuth, elevation], dim=-1) # [B, H, W, 2]
334
+
335
+
336
+ def world_to_ray_mats(
337
+ d_cam: torch.Tensor, # [B, H, W, 3]
338
+ c2w: torch.Tensor, # [B, T, 4, 4]
339
+ ) -> torch.Tensor:
340
+ """
341
+ 构造每条 ray 的世界到 ray 局部坐标系的变换矩阵 world2ray。
342
+ 坐标系定义:
343
+ - z: ray direction
344
+ - x: cam_y × ray_dir
345
+ - y: z × x
346
+ 返回:
347
+ raymats: [B, T, H, W, 4, 4]
348
+ """
349
+ B, H, W, _ = d_cam.shape
350
+ T = c2w.shape[1]
351
+ device = d_cam.device
352
+ dtype = d_cam.dtype
353
+
354
+ # --- Expand ray dirs across frames ---
355
+ # [B,H,W,3] -> [B,T,H,W,3]
356
+ d_cam = repeat(d_cam, 'b h w c -> b t h w c', t=T)
357
+
358
+ # extract camera R,t
359
+ R_cam = c2w[..., :3, :3] # [B,T,3,3]
360
+ t_cam = c2w[..., :3, 3] # [B,T,3]
361
+
362
+ # --- d_world: rotate ray directions into world ---
363
+ d_world = einsum(R_cam, d_cam, 'b t i j, b t h w j -> b t h w i')
364
+
365
+ # camera y-axis from each view
366
+ cam_y = R_cam[..., :, 1] # [B,T,3]
367
+ cam_y = repeat(cam_y, 'b t c -> b t h w c', h=H, w=W)
368
+
369
+ # === Construct orthonormal ray-local axes ===
370
+ z_ray = F.normalize(d_world, dim=-1, eps=1e-6)
371
+ x_ray = torch.cross(cam_y, z_ray, dim=-1)
372
+ x_ray = F.normalize(x_ray, dim=-1, eps=1e-6)
373
+ y_ray = torch.cross(z_ray, x_ray, dim=-1)
374
+ y_ray = F.normalize(y_ray, dim=-1, eps=1e-6)
375
+
376
+ # local->world rotation
377
+ R_l2w = torch.stack([x_ray, y_ray, z_ray], dim=-1) # [B,T,H,W,3,3]
378
+
379
+ # world->local rotation (transpose)
380
+ R_w2l = rearrange(R_l2w, 'b t h w i j -> b t h w j i') # ✅
381
+
382
+ # broadcast camera center
383
+ t_world = repeat(t_cam, 'b t c -> b t h w c', h=H, w=W)
384
+
385
+ # world->local translation
386
+ t_w2l = -einsum(R_w2l, t_world, 'b t h w i j, b t h w j -> b t h w i')
387
+
388
+ # assemble transform matrix
389
+ raymats = torch.zeros(B, T, H, W, 4, 4, device=device, dtype=dtype)
390
+ raymats[..., :3, :3] = R_w2l
391
+ raymats[..., :3, 3] = t_w2l
392
+ raymats[..., 3, 3] = 1.0
393
+
394
+ # NaN handling
395
+ mask = torch.isnan(d_world).any(-1)
396
+ raymats[mask] = torch.eye(4, device=device, dtype=dtype)
397
+
398
+ return raymats
399
+
400
+
401
+ def rope_precompute_coeffs(
402
+ positions: torch.Tensor, # [B, H, W]
403
+ freq_base: float,
404
+ freq_scale: float,
405
+ feat_dim: int,
406
+ dtype: torch.dtype = torch.float32,
407
+ ) -> Tuple[torch.Tensor, torch.Tensor]: # [B, 1, H*W, D], [B, 1, H*W, D]
408
+ """
409
+ 批量计算每个样本对应的 RoPE 系数(cos, sin),用于 patch ray angle embedding。
410
+ 输入:
411
+ positions: [B, H, W] —— 每个 patch 的 azimuth 或 elevation(单位弧度)
412
+ 输出:
413
+ cos: [B, 1, H*W, feat_dim//2]
414
+ sin: [B, 1, H*W, feat_dim//2]
415
+ """
416
+ # 对 NaN 角度 patch,输出 cos=1, sin=0,即不做旋转,等价于保留原始 token 表示
417
+ mask = positions.isnan()
418
+ positions = positions.clone()
419
+ positions[mask] = 0.0
420
+
421
+ B, H, W = positions.shape
422
+ positions_flat = positions.view(B, H * W) # [B, HW]
423
+ num_freqs = feat_dim // 2
424
+
425
+ freqs = freq_scale * (
426
+ freq_base ** (
427
+ -torch.arange(num_freqs, device=positions.device)[None, :]
428
+ / num_freqs
429
+ ) # [1, D]
430
+ ) # [1, D]
431
+
432
+ # Expand for batch & positions
433
+ angles = positions_flat[..., None] * freqs[None, :, :] # [B, HW, D]
434
+ angles = angles.view(B, 1, H * W, num_freqs)
435
+
436
+ return torch.cos(angles).to(dtype), torch.sin(angles).to(dtype)
437
+
438
+
439
+ def compute_up_lat_map(
440
+ R: torch.Tensor,
441
+ x_fov: torch.Tensor,
442
+ xi: torch.Tensor,
443
+ height: int,
444
+ width: int,
445
+ device: torch.device = torch.device("cpu"),
446
+ delta: float = 0.1,
447
+ ):
448
+ """
449
+ 计算 up_map 和 lat_map。
450
+
451
+ Args:
452
+ R: [B, T, 3, 3] 相机 c2w 旋转矩阵
453
+ x_fov: [B] 或 [B,T] 水平视场角(度)
454
+ xi: [B] 或 [B,T] UCM 参数
455
+ height: int,图像/patch 高度
456
+ width: int,图像/patch 宽度
457
+ device: torch.device
458
+ delta: float,小旋转角度(弧度)
459
+ Returns:
460
+ up_map: [B, T, H, W, 2] 单位向量 map
461
+ lat_map: [B, T, H, W, 1] 纬度 map
462
+ """
463
+ B, T, _, _ = R.shape
464
+ dtype = R.dtype
465
+ R = R.float()
466
+
467
+ # Step1:生成每像素射线方向(相机坐标系)
468
+ d_cam = ucm_unproject_grid_fov(
469
+ x_fov=x_fov,
470
+ xi=xi,
471
+ height=height,
472
+ width=width,
473
+ device=device,
474
+ dtype=torch.float32,
475
+ ) # [B, H, W, 3]
476
+ if d_cam.ndim == 3:
477
+ d_cam = d_cam.unsqueeze(0) # [B, H, W, 3]
478
+ mask = d_cam.isnan().any(dim=-1, keepdim=True) # [B, H, W, 1]
479
+
480
+ # Step2:从相机系旋转到世界���
481
+ d_cam_exp = repeat(d_cam, "B H W C -> B T H W C", T=T) # [B, T, H, W, 3]
482
+ d_world = torch.einsum('btij,bthwj->bthwi', R, d_cam_exp)
483
+ d_world = d_world / torch.clamp_min(d_world.norm(dim=-1, keepdim=True), 1e-8)
484
+
485
+ # Step3:计算纬度 map
486
+ Xw, Yw, Zw = d_world[..., 0], d_world[..., 1], d_world[..., 2]
487
+ lat_map = torch.atan2(-Yw, torch.sqrt(Xw**2 + Zw**2)).unsqueeze(-1) # [B, T, H, W, 1]
488
+
489
+ # Step4:计算 up_map
490
+ v = d_world # 已归一化
491
+ up_world = torch.tensor([0, -1, 0], device=device, dtype=torch.float32) # 世界上方方向(+Y 向下设定)
492
+ k = torch.cross(v, up_world.unsqueeze(0).unsqueeze(0).unsqueeze(0).expand_as(v), dim=-1)
493
+ k = k / torch.clamp_min(k.norm(dim=-1, keepdim=True), 1e-8)
494
+
495
+ delta = torch.tensor(delta, device=device, dtype=torch.float32)
496
+ cos_eps = torch.cos(delta)
497
+ sin_eps = torch.sin(delta)
498
+ # Rodrigues 公式旋转 v → v_rot
499
+ v_rot = v * cos_eps + torch.cross(k, v, dim=-1) * sin_eps + k * (k * (v * 1).sum(dim=-1, keepdim=True)) * (1 - cos_eps)
500
+
501
+ dirs_cam = torch.einsum('btij,bthwj->bthwi', R.transpose(-1, -2), v_rot)
502
+ Xs, Ys, Zs = dirs_cam[..., 0], dirs_cam[..., 1], dirs_cam[..., 2]
503
+
504
+ du, dv = project_ucm_points_fov(
505
+ Xs, Ys, Zs,
506
+ x_fov=x_fov.float(),
507
+ xi=xi.float(),
508
+ height=height,
509
+ width=width,
510
+ )
511
+ from equilib.torch_utils import create_grid
512
+ grid = create_grid(
513
+ height=height,
514
+ width=width,
515
+ batch=B,
516
+ dtype=torch.float32,
517
+ device=device,
518
+ ) # [B, H, W, 3]
519
+ grid_x = grid[..., 0].unsqueeze(1) # [B,1,H,W]
520
+ grid_y = grid[..., 1].unsqueeze(1)
521
+
522
+ up_map = torch.stack((du - grid_x, dv - grid_y), dim=-1) # [B, T, H, W, 2]
523
+ up_map = up_map / torch.clamp_min(up_map.norm(dim=-1, keepdim=True), 1e-8)
524
+
525
+ up_map = up_map.to(dtype=dtype)
526
+ lat_map = lat_map.to(dtype=dtype)
527
+
528
+ # 扩 mask 到同 shape
529
+ mask_exp2 = mask.unsqueeze(1).expand(B, T, height, width, 1)
530
+ up_map = up_map.masked_fill(mask_exp2, 0.0)
531
+ lat_map = lat_map.masked_fill(mask_exp2, 0.0)
532
+
533
+ return up_map, lat_map
534
+
535
+
536
+ def visualize_up_lat_map(up_map: torch.Tensor, lat_map: torch.Tensor, save_path: str = None):
537
+ """
538
+ 可视化 world-anchored 的 up_map 与 lat_map(GeoCalib-style overlay)。
539
+ 仅依赖指定输入,其余设置在函数内固定。
540
+
541
+ Args:
542
+ up_map: [H, W, 2] 张量
543
+ lat_map: [H, W, 1] 张量
544
+ save_path: 保存文件路径
545
+ """
546
+ import matplotlib.pyplot as plt
547
+ from geocalib import viz2d
548
+
549
+ # --- 数据预处理 ---
550
+ up_vis = up_map.detach().float().cpu() # [H, W, 2]
551
+ lat_vis = lat_map[..., 0].detach().float().cpu() # [H, W]
552
+
553
+ # --- 绘图 ---
554
+ fig, ax = plt.subplots(figsize=(6, 4), dpi=200)
555
+ viz2d.plot_latitudes([lat_vis], axes=[ax]) # 绘制纬度热力图
556
+ viz2d.plot_vector_fields([up_vis.permute(2, 0, 1)], subsample=10, axes=[ax]) # 绘制up向量场
557
+
558
+ ax.set_axis_off()
559
+
560
+ # --- 保存结果 ---
561
+ if save_path is not None:
562
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
563
+ fig.canvas.draw()
564
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
565
+ plt.close(fig)
566
+ else:
567
+ return fig
568
+
569
+
570
+ class UcpeSelfAttention(nn.Module):
571
+ def __init__(
572
+ self,
573
+ dim: int,
574
+ attn_dim: int,
575
+ num_heads: int,
576
+ patches_x: int = 8,
577
+ patches_y: int = 8,
578
+ image_width: int = 128,
579
+ image_height: int = 128,
580
+ freq_base: float = 100.0,
581
+ freq_scale: float = 1.0,
582
+ precompute_coeffs: bool = True,
583
+ emb_dim: int | None = None,
584
+ adaptation_method: str = "parallel",
585
+ ):
586
+ super().__init__()
587
+ assert dim % num_heads == 0
588
+ self.dim = dim
589
+ self.attn_dim = attn_dim
590
+ self.num_heads = num_heads
591
+ self.head_dim = attn_dim // num_heads
592
+ self.patches_x = patches_x
593
+ self.patches_y = patches_y
594
+ self.image_width = image_width
595
+ self.image_height = image_height
596
+ self.freq_base = freq_base
597
+ self.freq_scale = freq_scale
598
+ self.adaptation_method = adaptation_method
599
+
600
+ self.q_proj = nn.Linear(dim, attn_dim)
601
+ self.k_proj = nn.Linear(dim, attn_dim)
602
+ self.v_proj = nn.Linear(dim, attn_dim)
603
+ self.out_proj = nn.Linear(attn_dim, dim)
604
+ if emb_dim is not None:
605
+ self.cam_encoder = nn.Linear(emb_dim, dim)
606
+
607
+ # 初始化为零以增强 residual 训练稳定性
608
+ nn.init.zeros_(self.out_proj.weight)
609
+ nn.init.zeros_(self.out_proj.bias)
610
+
611
+ # 初始化 PRoPE attention 模块(带 precomputed coeffs)
612
+ self.prope_attn = PropeDotProductAttention(
613
+ head_dim=self.head_dim,
614
+ patches_x=patches_x,
615
+ patches_y=patches_y,
616
+ image_width=image_width,
617
+ image_height=image_height,
618
+ freq_base=freq_base,
619
+ freq_scale=freq_scale,
620
+ precompute_coeffs=precompute_coeffs,
621
+ )
622
+
623
+ def forward(self, x: torch.Tensor, control_camera_dit_input: dict):
624
+ """
625
+ Args:
626
+ x: (B, T, D) — input tokens
627
+ control_camera_dit_input: dict with keys:
628
+ - viewmats: (B, N, 4, 4)
629
+ - K: (B, N, 3, 3)
630
+ """
631
+ B, T, D = x.shape
632
+ N = control_camera_dit_input["viewmats"].shape[1] # number of cameras
633
+ H, W = self.patches_y, self.patches_x
634
+ assert T == N * H * W or T == N, f"Expected token shape ({N}×{H}×{W} or {N}), got {T}"
635
+
636
+ if hasattr(self, "cam_encoder") and "cam_emb" in control_camera_dit_input:
637
+ cam_emb = control_camera_dit_input["cam_emb"]
638
+ y = self.cam_encoder(cam_emb)
639
+ if y.shape[1] != T:
640
+ hw = T // cam_emb.shape[1]
641
+ y = repeat(y, "b f d -> b (f hw) d", hw=hw)
642
+ x = x + y
643
+
644
+ # Project Q, K, V
645
+ q = self.q_proj(x).view(B, T, self.num_heads, self.head_dim).transpose(1, 2) # [B, H, T, D_head]
646
+ k = self.k_proj(x).view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
647
+ v = self.v_proj(x).view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
648
+
649
+ # Precompute camera-specific functions (only once per batch)
650
+ self.prope_attn._precompute_and_cache_apply_fns(
651
+ viewmats=control_camera_dit_input["viewmats"],
652
+ Ks=control_camera_dit_input.get("K", None),
653
+ coeffs_x=control_camera_dit_input.get("coeffs_x", None),
654
+ coeffs_y=control_camera_dit_input.get("coeffs_y", None),
655
+ )
656
+
657
+ # Apply RoPE-style positional encoding
658
+ q = self.prope_attn._apply_to_q(q) # [B, H, T, D_head]
659
+ k = self.prope_attn._apply_to_kv(k)
660
+ v = self.prope_attn._apply_to_kv(v)
661
+
662
+ # Rearrange to [B, T, D] for flash_attention input
663
+ q = rearrange(q, "b h t d -> b t (h d)")
664
+ k = rearrange(k, "b h t d -> b t (h d)")
665
+ v = rearrange(v, "b h t d -> b t (h d)")
666
+
667
+ # Fast attention (Flash/Sage/SDPA fallback)
668
+ out = flash_attention(q, k, v, num_heads=self.num_heads)
669
+
670
+ # reshape back
671
+ out = rearrange(out, "b t (h d) -> b h t d", h=self.num_heads)
672
+
673
+ # Apply inverse transform for PRoPE
674
+ out = self.prope_attn._apply_to_o(out)
675
+
676
+ # Final projection
677
+ out = out.transpose(1, 2).reshape(B, T, -1)
678
+ return self.out_proj(out)
UCPE/src/dataset.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch.utils.data import Dataset
3
+ from einops import rearrange, repeat
4
+ from pathlib import Path
5
+ import jsonlines
6
+ import json
7
+ import numpy as np
8
+ import math
9
+ from tqdm.auto import tqdm
10
+ from src import camera_control as ucpe
11
+
12
+
13
+ class PanShotDataset(Dataset):
14
+ def __init__(self, args, split, load_keys=["video"], video_ids=None, skip_cached=False, result_root=None):
15
+ self.args = args
16
+ self.data_root = Path(args.data_root)
17
+ self.load_keys = load_keys
18
+ self.split = split # "train" or "test"
19
+
20
+ near_depth_file = self.data_root / "PanFlow" / f"near_plane_depth-{split}.jsonl"
21
+ near_depths = {}
22
+ with jsonlines.open(near_depth_file) as reader:
23
+ for obj in reader:
24
+ video_id = obj["video_id"]
25
+ clip_id = obj["clip_id"]
26
+ near_depths[f"{video_id}-{clip_id}"] = obj["near_depth"]
27
+ print(f"Loaded {len(near_depths)} near depth entries.")
28
+
29
+ meta_path = self.data_root / "PanShot" / f"meta-{split}"
30
+ meta_files = list(meta_path.glob("*.json"))
31
+ metas = {}
32
+ for meta_file in meta_files:
33
+ with open(meta_file, "r") as f:
34
+ matches = json.load(f)
35
+ for match in matches:
36
+ for video in match["videos"]:
37
+ meta = {
38
+ "pose_id": video["pose"],
39
+ "x_fov": float(video["x_fov"]),
40
+ "xi": video["xi"],
41
+ "near_depth": near_depths[meta_file.stem],
42
+ }
43
+ # estimate y_fov by 16:9 aspect ratio
44
+ fx = ucpe.compute_fx_from_fov_xi(meta["x_fov"], meta["xi"], 16)
45
+ y_fov = ucpe.compute_fov_from_fx_xi(fx, meta["xi"], 9)
46
+ meta["y_fov"] = float(y_fov)
47
+
48
+ metas[video["video"]] = meta
49
+
50
+ print(f"Loaded {len(metas)} video metas.")
51
+
52
+ self.metas = []
53
+ caption_file = self.data_root / "PanShot" / f"captioned-{split}.jsonl"
54
+ with jsonlines.open(caption_file) as reader:
55
+ for obj in reader:
56
+ if obj["video"] not in metas:
57
+ continue
58
+ meta = metas[obj["video"]]
59
+ meta["caption"] = obj["caption"]
60
+ meta["video_id"] = obj["video"]
61
+ self.metas.append(meta)
62
+ print(f"Loaded {len(self.metas)} captioned videos.")
63
+
64
+ if "model_id" in args:
65
+ self.cache_prefix = f"cache-{args.model_id.split('/')[-1]}"
66
+ self.cache_folder = self.data_root / "PanShot" / f"{self.cache_prefix}-{split}"
67
+ cache_names = set(c.stem for c in self.cache_folder.glob("*.pth"))
68
+ print(f"Found {len(cache_names)} cached videos.")
69
+ if skip_cached:
70
+ self.metas = [m for m in self.metas if m["video_id"] not in cache_names]
71
+ print(f"Skipped cached, {len(self.metas)} videos remaining.")
72
+ elif "cache" in self.load_keys:
73
+ self.metas = [m for m in self.metas if m["video_id"] in cache_names]
74
+ print(f"Only use cached, {len(self.metas)} videos remaining.")
75
+
76
+ if video_ids is not None:
77
+ self.metas = [meta for meta in self.metas if meta["video_id"] in video_ids]
78
+ print(f"Filtered by video_ids, {len(self.metas)} videos remaining.")
79
+
80
+ self.result_root = None
81
+ if result_root is not None:
82
+ self.result_root = Path(result_root)
83
+ video_ids = set(v.stem for v in self.result_root.glob("*.mp4"))
84
+ self.metas = [m for m in self.metas if m["video_id"] in video_ids]
85
+ print(f"Filtered by result_root, {len(self.metas)} videos remaining.")
86
+
87
+ def __len__(self):
88
+ return max(len(self.metas), 1)
89
+
90
+ def __getitem__(self, idx):
91
+ data = self.metas[idx].copy()
92
+
93
+ video_id = data["video_id"]
94
+ video_path = self.data_root / "PanShot" / f"videos-{self.split}" / f"{video_id}.mp4"
95
+ data["video_path"] = str(video_path)
96
+ data["result_path"] = data["video_path"] if self.result_root is None else str(self.result_root / f"{video_id}.mp4")
97
+
98
+ if "image_path" in self.load_keys:
99
+ image_path = self.data_root / "PanShot" / f"images-{self.split}" / f"{video_id}.png"
100
+ data["image_path"] = str(image_path)
101
+ if not image_path.exists():
102
+ from decord import VideoReader, cpu
103
+ from PIL import Image
104
+
105
+ vr = VideoReader(str(video_path), ctx=cpu(0), num_threads=1)
106
+ first_frame = vr[0].asnumpy()
107
+ first_frame = Image.fromarray(first_frame)
108
+ image_path.parent.mkdir(parents=True, exist_ok=True)
109
+ first_frame.save(image_path)
110
+
111
+ for key, path in [(k, data[f"{k}_path"]) for k in ["video", "result"] if k in self.load_keys]:
112
+ try:
113
+ from decord import VideoReader, cpu
114
+ vr = VideoReader(str(path), ctx=cpu(0), num_threads=1)
115
+ video = vr.get_batch(range(len(vr))).asnumpy()
116
+ except Exception as e:
117
+ alt_idx = (idx + 32) % len(self)
118
+ print(f"Error reading video {path}: {e}")
119
+ print(f"Use video {alt_idx} instead.")
120
+ return PanShotDataset.__getitem__(self, alt_idx)
121
+ video = video.astype(np.float32)
122
+ video = video / 255.0 * 2 - 1 # to [-1, 1]
123
+ video = rearrange(video, "T H W C -> C T H W")
124
+ data[key] = video
125
+ data["fps"] = float(vr.get_avg_fps())
126
+
127
+ if "input_image" in self.load_keys:
128
+ data["input_image"] = video[:, 0]
129
+
130
+ if "cache" in self.load_keys:
131
+ cache_path = self.cache_folder / f"{data['video_id']}.pth"
132
+ data |= torch.load(cache_path, map_location="cpu")
133
+
134
+ if "pose" in self.load_keys:
135
+ pose_file = self.data_root / "PanShot" / f"pose-{self.split}" / data["pose_id"]
136
+ pose_file = pose_file.with_suffix(".npy")
137
+ pose = np.load(pose_file)
138
+ pose[..., 3] /= data["near_depth"]
139
+
140
+ if getattr(self.args, "zero_first_yaw", True):
141
+ # Rotate all cameras around world-y
142
+ # to move forward-z of the first camera to y-z plane
143
+ forward = pose[0, :, 2] # (3,) the z-axis of the first camera in world
144
+ forward_xy = np.array([forward[0], 0, forward[2]]) # project to x-z plane
145
+ forward_xy /= np.linalg.norm(forward_xy) + 1e-8
146
+
147
+ # compute rotation angle theta = atan2(x, z)
148
+ theta = np.arctan2(forward_xy[0], forward_xy[2])
149
+
150
+ # rotation matrix around world-y by -theta
151
+ c, s = np.cos(-theta), np.sin(-theta)
152
+ R_y = np.array([[c, 0, s],
153
+ [0, 1, 0],
154
+ [-s, 0, c]], dtype=pose.dtype)
155
+
156
+ # apply rotation to all camera extrinsics
157
+ pose[..., :3] = (R_y[None] @ pose[..., :3])
158
+ pose[..., 3] = (R_y[None] @ pose[..., 3:4]).squeeze(-1)
159
+ else:
160
+ last_row = repeat(np.array([0,0,0,1], dtype=pose.dtype), "n -> t 1 n", t=pose.shape[0])
161
+ c2w = np.concatenate([pose, last_row], axis=-2) # (T, 4, 4)
162
+ w2c0= np.linalg.inv(c2w[0]) # (4, 4)
163
+ c2w = w2c0[None] @ c2w # (T, 4, 4)
164
+ pose = c2w[:, :3] # (T, 3, 4)
165
+
166
+ data["pose"] = pose
167
+
168
+ return data
169
+
170
+
171
+ class Re10kDataset(Dataset):
172
+ def __init__(self, args, split, load_keys=["pose"], video_ids=None, result_root=None):
173
+ self.args = args
174
+ self.data_root = Path(args.data_root)
175
+ self.load_keys = load_keys
176
+ self.split = split # "train" or "test"
177
+ self.normalize_traj = getattr(args, "normalize_traj", None)
178
+
179
+ self.pose_path = self.data_root / "pose_files" / split
180
+
181
+ caption_file = self.data_root / "captions" / f"{split}.json"
182
+ with open(caption_file, "r") as f:
183
+ captions = json.load(f)
184
+ print(f"Loaded {len(captions)} captions.")
185
+
186
+ self.metas = []
187
+ for video_name, caption in captions.items():
188
+ video_id = Path(video_name).stem
189
+ pose_file = self.pose_path / f"{video_id}.txt"
190
+ if not pose_file.exists():
191
+ continue
192
+ self.metas.append({
193
+ "video_id": video_id,
194
+ "caption": caption[0],
195
+ })
196
+ print(f"Total {len(self.metas)} videos with poses.")
197
+
198
+ self.result_root = None
199
+ filter_file = Path(args.data_root) / "filter_files" / f"filter_{split}_{args.num_frames}.txt"
200
+ if not filter_file.exists():
201
+ self.filter_frames(filter_file)
202
+ if torch.distributed.is_available() and torch.distributed.is_initialized():
203
+ torch.distributed.barrier()
204
+ with open(filter_file, "r") as f:
205
+ filtered_ids = set(ln.strip() for ln in f.readlines() if ln.strip())
206
+ self.metas = [m for m in self.metas if m["video_id"] in filtered_ids]
207
+ print(f"After filtering, {len(self.metas)} videos remaining.")
208
+
209
+ if video_ids is not None:
210
+ self.metas = [meta for meta in self.metas if meta["video_id"] in video_ids]
211
+ print(f"Filtered by video_ids, {len(self.metas)} videos remaining.")
212
+
213
+ if result_root is not None:
214
+ self.result_root = Path(result_root)
215
+ video_ids = set(v.stem for v in self.result_root.glob("*.mp4"))
216
+ self.metas = [m for m in self.metas if m["video_id"] in video_ids]
217
+ print(f"Filtered by result_root, {len(self.metas)} videos remaining.")
218
+
219
+ def __len__(self):
220
+ return len(self.metas)
221
+
222
+ def filter_frames(self, filter_file):
223
+ if torch.distributed.is_available() and torch.distributed.is_initialized() and torch.distributed.get_rank() != 0:
224
+ return
225
+ Path(filter_file).parent.mkdir(parents=True, exist_ok=True)
226
+ with open(filter_file, "w") as f:
227
+ for data in tqdm(self, desc=f"Filtering {self.split} set"):
228
+ video_id = data["video_id"]
229
+ if len(data["pose"]) >= self.args.num_frames:
230
+ f.write(f"{video_id}\n")
231
+ print(f"Filter file saved to {filter_file}.")
232
+
233
+ def __getitem__(self, idx):
234
+ data = self.metas[idx].copy()
235
+ pose_file = self.pose_path / f"{data['video_id']}.txt"
236
+
237
+ if self.result_root is not None:
238
+ video_id = data["video_id"]
239
+ data["result_path"] = str(self.result_root / f"{video_id}.mp4")
240
+
241
+ if "result" in self.load_keys:
242
+ path = data["result_path"]
243
+ try:
244
+ from decord import VideoReader, cpu
245
+ vr = VideoReader(str(path), ctx=cpu(0), num_threads=1)
246
+ video = vr.get_batch(range(len(vr))).asnumpy()
247
+ except Exception as e:
248
+ alt_idx = (idx + 32) % len(self)
249
+ print(f"Error reading video {path}: {e}")
250
+ print(f"Use video {alt_idx} instead.")
251
+ return PanShotDataset.__getitem__(self, alt_idx)
252
+ video = video.astype(np.float32)
253
+ video = video / 255.0 * 2 - 1 # to [-1, 1]
254
+ video = rearrange(video, "T H W C -> C T H W")
255
+ data["result"] = video
256
+ data["fps"] = float(vr.get_avg_fps())
257
+
258
+ if "pose" in self.load_keys:
259
+ num_frames = self.args.num_frames
260
+ else:
261
+ num_frames = 1
262
+ with open(pose_file, "r") as f:
263
+ # lines = [ln.strip() for ln in f.readlines() if ln.strip() and not ln.startswith("http")]
264
+ lines = []
265
+ for line in f:
266
+ line = line.strip()
267
+ if line and not line.startswith("http"):
268
+ lines.append(line)
269
+ if len(lines) >= num_frames:
270
+ break
271
+
272
+ if "pose" in self.load_keys:
273
+ poses = []
274
+ for line in lines:
275
+ parts = line.split()
276
+ pose = np.array(list(map(float, parts[7:]))).reshape(3, 4)
277
+ poses.append(pose)
278
+ poses = np.stack(poses, axis=0) # [T, 3, 4]
279
+ last_row = repeat(np.array([0,0,0,1], dtype=poses.dtype), "n -> t 1 n", t=poses.shape[0])
280
+ w2c = np.concatenate([poses, last_row], axis=-2) # (T, 4, 4)
281
+ c2w = np.linalg.inv(w2c) # (T, 4, 4)
282
+ w2c0= np.linalg.inv(c2w[0]) # (4, 4)
283
+ c2w = w2c0[None] @ c2w # (T, 4, 4)
284
+ poses = c2w[:, :3] # (T, 3, 4)
285
+ if self.normalize_traj is not None:
286
+ poses[..., 3] *= self.normalize_traj
287
+ data["pose"] = poses
288
+
289
+ fx = float(lines[0].split()[1])
290
+ fy = float(lines[0].split()[2])
291
+ x_fov = float(2 * math.atan(0.5 / fx) * 180 / math.pi)
292
+ y_fov = float(2 * math.atan(0.5 / fy) * 180 / math.pi)
293
+ overwrite_xfov = getattr(self.args, "overwrite_xfov", None)
294
+ data["x_fov"] = x_fov if overwrite_xfov is None else overwrite_xfov
295
+ data["y_fov"] = y_fov
296
+ data["xi"] = 0.0 # pinhole camera
297
+
298
+ return data
299
+
300
+
301
+ class DemoDataset(Dataset):
302
+ def __init__(self, args, split=None, load_keys=["pose"], video_ids=None, result_root=None):
303
+ self.args = args
304
+ self.panshot_data_root = Path(args.panshot_data_root)
305
+ self.load_keys = load_keys
306
+ with open(args.input_file, "r") as f:
307
+ self.metas = json.load(f)
308
+ self.normalize_traj = getattr(args, "re10k_normalize_traj", None)
309
+
310
+ near_depth_file = self.panshot_data_root / "PanFlow" / "near_plane_depth-test.jsonl"
311
+ near_depths = {}
312
+ with jsonlines.open(near_depth_file) as reader:
313
+ for obj in reader:
314
+ video_id = obj["video_id"]
315
+ clip_id = obj["clip_id"]
316
+ near_depths[f"{video_id}-{clip_id}"] = obj["near_depth"]
317
+ print(f"Loaded {len(near_depths)} near depth entries.")
318
+ for meta in self.metas:
319
+ pose_file = Path(meta["pose_path"])
320
+ if pose_file.suffix == ".npy":
321
+ clip_name = pose_file.stem.rsplit("-", 2)[0]
322
+ meta["near_depth"] = near_depths[clip_name]
323
+
324
+ for idx, data in enumerate(self.metas):
325
+ pose_file = Path(data["pose_path"])
326
+ prefix = f"{idx}-{pose_file.stem}-fov{int(data['x_fov'])}-xi{data['xi']:.2f}-"
327
+ data["video_id"] = prefix + data["caption"][:50].replace(" ", "_")
328
+
329
+ if video_ids is not None:
330
+ self.metas = [meta for meta in self.metas if meta["video_id"] in video_ids]
331
+ print(f"Filtered by video_ids, {len(self.metas)} videos remaining.")
332
+
333
+ self.result_root = None
334
+ if result_root is not None:
335
+ self.result_root = Path(result_root)
336
+ new_metas = []
337
+ metas = {m["video_id"]: m for m in self.metas}
338
+ results = list(self.result_root.glob("*.mp4"))
339
+ results.sort()
340
+ for v in results:
341
+ video_id = v.stem.rsplit("-", 1)[0]
342
+ if video_id in metas:
343
+ meta = metas[video_id].copy()
344
+ meta["result_path"] = str(v)
345
+ new_metas.append(meta)
346
+ self.metas = new_metas
347
+ print(f"Filtered by result_root, {len(self.metas)} videos remaining.")
348
+
349
+ def __len__(self):
350
+ return len(self.metas)
351
+
352
+ def __getitem__(self, idx):
353
+ data = self.metas[idx].copy()
354
+
355
+ if "result" in self.load_keys:
356
+ path = data["result_path"]
357
+ try:
358
+ from decord import VideoReader, cpu
359
+ vr = VideoReader(str(path), ctx=cpu(0), num_threads=1)
360
+ video = vr.get_batch(range(len(vr))).asnumpy()
361
+ except Exception as e:
362
+ alt_idx = (idx + 32) % len(self)
363
+ print(f"Error reading video {path}: {e}")
364
+ print(f"Use video {alt_idx} instead.")
365
+ return PanShotDataset.__getitem__(self, alt_idx)
366
+ video = video.astype(np.float32)
367
+ video = video / 255.0 * 2 - 1 # to [-1, 1]
368
+ video = rearrange(video, "T H W C -> C T H W")
369
+ data["result"] = video
370
+ data["fps"] = float(vr.get_avg_fps())
371
+
372
+ if "pose" in self.load_keys:
373
+ pose_file = Path(data["pose_path"])
374
+ if pose_file.suffix == ".txt":
375
+ with open(pose_file, "r") as f:
376
+ lines = []
377
+ for line in f:
378
+ line = line.strip()
379
+ if line and not line.startswith("http"):
380
+ lines.append(line)
381
+ if len(lines) >= self.args.num_frames:
382
+ break
383
+ poses = []
384
+ for line in lines:
385
+ parts = line.split()
386
+ pose = np.array(list(map(float, parts[7:]))).reshape(3, 4)
387
+ poses.append(pose)
388
+ poses = np.stack(poses, axis=0) # [T, 3, 4]
389
+ last_row = repeat(np.array([0,0,0,1], dtype=poses.dtype), "n -> t 1 n", t=poses.shape[0])
390
+ w2c = np.concatenate([poses, last_row], axis=-2) # (T, 4, 4)
391
+ c2w = np.linalg.inv(w2c) # (T, 4, 4)
392
+ w2c0= np.linalg.inv(c2w[0]) # (4, 4)
393
+ c2w = w2c0[None] @ c2w # (T, 4, 4)
394
+ poses = c2w[:, :3] # (T, 3, 4)
395
+ if self.normalize_traj is not None:
396
+ poses[..., 3] *= self.normalize_traj
397
+ data["pose"] = poses
398
+ elif pose_file.suffix == ".npy":
399
+ pose = np.load(pose_file)
400
+ pose[..., 3] /= data["near_depth"]
401
+
402
+ if getattr(self.args, "zero_first_yaw", True):
403
+ # Rotate all cameras around world-y
404
+ # to move forward-z of the first camera to y-z plane
405
+ forward = pose[0, :, 2] # (3,) the z-axis of the first camera in world
406
+ forward_xy = np.array([forward[0], 0, forward[2]]) # project to x-z plane
407
+ forward_xy /= np.linalg.norm(forward_xy) + 1e-8
408
+
409
+ # compute rotation angle theta = atan2(x, z)
410
+ theta = np.arctan2(forward_xy[0], forward_xy[2])
411
+
412
+ # rotation matrix around world-y by -theta
413
+ c, s = np.cos(-theta), np.sin(-theta)
414
+ R_y = np.array([[c, 0, s],
415
+ [0, 1, 0],
416
+ [-s, 0, c]], dtype=pose.dtype)
417
+
418
+ # apply rotation to all camera extrinsics
419
+ pose[..., :3] = (R_y[None] @ pose[..., :3])
420
+ pose[..., 3] = (R_y[None] @ pose[..., 3:4]).squeeze(-1)
421
+ else:
422
+ last_row = repeat(np.array([0,0,0,1], dtype=pose.dtype), "n -> t 1 n", t=pose.shape[0])
423
+ c2w = np.concatenate([pose, last_row], axis=-2) # (T, 4, 4)
424
+ w2c0= np.linalg.inv(c2w[0]) # (4, 4)
425
+ c2w = w2c0[None] @ c2w # (T, 4, 4)
426
+ pose = c2w[:, :3] # (T, 3, 4)
427
+
428
+ data["pose"] = pose
429
+ else:
430
+ raise NotImplementedError(f"Unsupported pose file: {pose_file}")
431
+
432
+ return data
UCPE/src/evaluate.py ADDED
@@ -0,0 +1,870 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ sys.path.append(os.getcwd())
4
+ import numpy as np
5
+ import torch
6
+ from torch import Tensor
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+ from pathlib import Path
9
+ from datetime import datetime
10
+ from typing import Any, List, Literal, Tuple, Optional, Dict
11
+ from src.dataset import PanShotDataset, Re10kDataset
12
+ from collections import defaultdict
13
+ from torch.utils.data import DataLoader, Subset
14
+ import json
15
+ from tqdm.auto import tqdm
16
+ import subprocess
17
+ from scipy.spatial.transform import Rotation as R
18
+
19
+
20
+ class Args(BaseSettings):
21
+ data: str = "PanShotDataset"
22
+ num_frames: int = 81 # for Re10kDataset
23
+ test_steps: List[str] = ["qalign", "video", "vipe", "pose", "overall"]
24
+ conda_envs: Dict = {"qalign": "qalign"}
25
+ data_root: Path = Path("data/UCPE")
26
+ num_workers: int = 2
27
+ test_device: Literal["cuda", "cpu"] = "cuda"
28
+ test_res_path: Optional[Path] = None
29
+ evaluate_gt: bool = False
30
+ test_name: str = datetime.now().strftime('%Y-%m-%d-%H:%M:%S')
31
+ limit_eval_videos: Optional[int] = None
32
+ save_last: bool = True
33
+ load_last: bool = True
34
+ jitter_filter_percent: float = 0.8
35
+
36
+ # qalign
37
+ qalign_fps: float = 4.0
38
+
39
+ # video
40
+ test_chunk_size: int = 8
41
+
42
+ # pose
43
+ valid_pose_percent: float = 0.5
44
+ frame_stride: Optional[int] = None
45
+ pose_frames: Optional[int] = None
46
+
47
+ model_config = SettingsConfigDict(
48
+ env_prefix="EVAL_",
49
+ cli_parse_args=True,
50
+ cli_ignore_unknown_args=False,
51
+ )
52
+
53
+
54
+ def get_path(args):
55
+ if args.evaluate_gt:
56
+ assert args.data == "PanShotDataset", "GT evaluation only supports PanShotDataset."
57
+ paths = {"i2v": (args.data_root / "PanShot" / "videos-test", args.data_root / "evaluate")}
58
+ elif args.test_res_path is not None:
59
+ paths = {args.test_res_path.name: (args.test_res_path, args.test_res_path.parent / f"evaluate_{args.test_res_path.name}")}
60
+ else:
61
+ run_id = os.environ.get('WANDB_RUN_ID', None)
62
+ assert run_id is not None, "WANDB_RUN_ID environment variable must be set."
63
+ paths = {}
64
+ split = "predict" if args.data == "PanShotDataset" else Path(args.data_root).name
65
+ predict_dir = Path("logs") / run_id / split
66
+ for task in ["t2v", "i2v"]:
67
+ task_path = predict_dir / task
68
+ if task_path.exists():
69
+ paths[task] = (task_path, predict_dir / f"evaluate_{task}")
70
+ print(f"Evaluation paths: {paths}")
71
+ return paths
72
+
73
+
74
+ def collate_fn(samples):
75
+ data = samples[0]
76
+ return data
77
+
78
+
79
+ def filter_jitter(args):
80
+ if args.jitter_filter_percent >= 1.0:
81
+ return
82
+
83
+ # Fix jittering rotation issue
84
+ # Filter out videos with rapid rotations
85
+ max_rotation_file = args.data_root / "PanShot" / "max_rotation-test.json"
86
+ if not max_rotation_file.exists() and args.jitter_filter_percent < 1.:
87
+ max_rotations = {}
88
+ dataset = PanShotDataset(args, "test", load_keys=["pose"])
89
+ for data in dataset:
90
+ R_all = data["pose"][:, :3, :3] # (T, 3, 3)
91
+ # 计算相邻帧之间的相对旋转
92
+ rel_rot = np.einsum("tij,tjk->tik", np.linalg.inv(R_all[:-1]), R_all[1:])
93
+ # 将相对旋转矩阵转换为旋转角度(度数)
94
+ rel_angles = R.from_matrix(rel_rot).magnitude() * 180.0 / np.pi
95
+ max_rotations[data['video_id']] = float(np.max(rel_angles))
96
+ max_rotations = dict(sorted(max_rotations.items(), key=lambda x: x[1]))
97
+ with open(max_rotation_file, "w") as f:
98
+ json.dump(max_rotations, f, indent=4)
99
+ else:
100
+ with open(max_rotation_file, "r") as f:
101
+ max_rotations = json.load(f)
102
+
103
+ num_videos = int(len(max_rotations) * args.jitter_filter_percent)
104
+ valid_video_ids = set(list(max_rotations.keys())[:num_videos])
105
+ print(f"Filtered {len(max_rotations) - num_videos} videos with high jittering rotations.")
106
+
107
+ return valid_video_ids
108
+
109
+
110
+ def prepare_dataloader(args, load_keys, result_root=None, video_ids=None):
111
+ dataset_class = globals().get(args.data, None)
112
+
113
+ if dataset_class is PanShotDataset:
114
+ valid_video_ids = filter_jitter(args)
115
+ if valid_video_ids is not None:
116
+ if video_ids is not None:
117
+ video_ids = set(video_ids) & valid_video_ids
118
+ else:
119
+ video_ids = valid_video_ids
120
+
121
+ dataset = dataset_class(args, "test", load_keys=load_keys, result_root=result_root, video_ids=video_ids)
122
+ if args.limit_eval_videos and args.limit_eval_videos < len(dataset):
123
+ print(f"Limiting evaluation to {args.limit_eval_videos} videos.")
124
+ sample_ids = np.linspace(0, len(dataset) - 1, args.limit_eval_videos).astype(int).tolist()
125
+ dataset = Subset(dataset, sample_ids)
126
+ dataloader = DataLoader(
127
+ dataset,
128
+ collate_fn=collate_fn,
129
+ batch_size=1,
130
+ num_workers=args.num_workers,
131
+ shuffle=False,
132
+ )
133
+ return dataloader
134
+
135
+
136
+ def link_last(output_path):
137
+ last_path = output_path.parent / "last.json"
138
+ if last_path.exists():
139
+ os.remove(last_path)
140
+ os.symlink(output_path.name, last_path)
141
+ print(f"Saved last evaluation results to {last_path}")
142
+
143
+
144
+ def save_evaluation(args, test_dir, eval_results, subfolder):
145
+ for key, values in eval_results.items():
146
+ if isinstance(values, list):
147
+ results = [v["video_results"] for v in values]
148
+ results = float(np.mean(results))
149
+ eval_results[key] = [results, values]
150
+ else:
151
+ eval_results[key] = [values]
152
+
153
+ output_folder = test_dir / subfolder
154
+ output_folder.mkdir(parents=True, exist_ok=True)
155
+ output_path = output_folder / f"{args.test_name}_eval_results.json"
156
+ with open(output_path, "w") as f:
157
+ json.dump(eval_results, f, indent=4)
158
+ print(f"Evaluation results saved to {output_path}")
159
+
160
+ if args.save_last:
161
+ link_last(output_path)
162
+
163
+
164
+ @torch.inference_mode()
165
+ def qalign(args):
166
+ from q_align import QAlignVideoScorer, QAlignAestheticScorer, QAlignScorer
167
+ from PIL import Image
168
+ from einops import rearrange, repeat
169
+
170
+ print("Running QAlign evaluation...")
171
+ tasks = get_path(args)
172
+
173
+ video_scorer = QAlignVideoScorer()
174
+ scorers = {
175
+ "image_aesthetic": QAlignAestheticScorer(
176
+ tokenizer=video_scorer.tokenizer,
177
+ model=video_scorer.model,
178
+ image_processor=video_scorer.image_processor
179
+ ),
180
+ "image_quality": QAlignScorer(
181
+ tokenizer=video_scorer.tokenizer,
182
+ model=video_scorer.model,
183
+ image_processor=video_scorer.image_processor
184
+ ),
185
+ "video_quality": video_scorer,
186
+ }
187
+
188
+ for task, (test_res_path, test_dir) in tasks.items():
189
+ print(f"Evaluating task: {task}")
190
+ dataloader = prepare_dataloader(args, ["result"], test_res_path)
191
+
192
+ eval_results = defaultdict(list)
193
+ for data in tqdm(dataloader, desc="Evaluating videos"):
194
+ frames = data["result"]
195
+ frames = rearrange(frames, "C T H W -> T H W C")
196
+ frame_count = len(frames) * args.qalign_fps / data["fps"]
197
+ frame_count = min(int(frame_count), len(frames))
198
+ frame_count = max(frame_count, 1)
199
+ frame_indices = np.linspace(0, len(frames) - 1, num=frame_count)
200
+ frame_indices = np.round(frame_indices).astype(int)
201
+ frames = frames[frame_indices]
202
+ frames = frames / 2. + 0.5
203
+ frames = (frames * 255.0).astype(np.uint8)
204
+ frames = [Image.fromarray(frame) for frame in frames]
205
+ video = [video_scorer.expand2square(frame, tuple(int(x*255) for x in video_scorer.image_processor.image_mean)) for frame in frames]
206
+ video_tensors = video_scorer.image_processor.preprocess(video, return_tensors="pt")["pixel_values"].half()
207
+
208
+ video_tensors = video_tensors.to(video_scorer.model.device)
209
+ for key, scorer in scorers.items():
210
+ images = video_tensors if "image" in key else [video_tensors]
211
+ output_logits = scorer.model(
212
+ scorer.input_ids.repeat(len(images), 1),
213
+ images=images
214
+ )["logits"][:, -1, scorer.preferential_ids_]
215
+
216
+ values = torch.softmax(output_logits, -1) @ scorer.weight_tensor
217
+ score = values.mean().cpu().item()
218
+ eval_results[key].append({
219
+ "video_id": data["video_id"],
220
+ "video_results": score,
221
+ })
222
+
223
+ save_evaluation(args, test_dir, eval_results, "qalign")
224
+
225
+
226
+ @torch.inference_mode()
227
+ def video(args):
228
+ import src.camera_control as ucpe
229
+ from unik3d.models import UniK3D
230
+ from unik3d.utils.evaluation_depth import rho
231
+ from torchmetrics import MeanMetric, Metric
232
+ from einops import rearrange, repeat
233
+ from torchmetrics.image import (
234
+ LearnedPerceptualImagePatchSimilarity,
235
+ PeakSignalNoiseRatio,
236
+ StructuralSimilarityIndexMeasure,
237
+ )
238
+ from torchmetrics.image.fid import FrechetInceptionDistance, _compute_fid
239
+ from torchmetrics.image.inception import InceptionScore
240
+ from torchmetrics.multimodal import CLIPScore
241
+ from thirdparty.fvd.fvd import (
242
+ load_i3d_pretrained,
243
+ get_fvd_logits,
244
+ )
245
+ from geocalib import GeoCalib
246
+ sys.path.append("thirdparty/GeoCalib")
247
+ from siclib.models.utils.metrics import (
248
+ gravity_error,
249
+ latitude_error,
250
+ pitch_error,
251
+ roll_error,
252
+ up_error,
253
+ vfov_error,
254
+ )
255
+
256
+ class GeoCalibError(Metric):
257
+ higher_is_better: bool = False
258
+
259
+ def __init__(
260
+ self,
261
+ chunk_size: int | None = None,
262
+ skip_frames: int = 4,
263
+ ):
264
+ super().__init__()
265
+ self.gc = GeoCalib(weights="distorted")
266
+ self.errors = torch.nn.ModuleDict({k: MeanMetric() for k in [
267
+ "pitch", "roll", "gravity", "vfov", "k1", "k2", "latitude", "up"
268
+ ]})
269
+ self.chunk_size = chunk_size
270
+ self.skip_frames = skip_frames
271
+
272
+ def update(
273
+ self,
274
+ pred: torch.Tensor, # [B, 3, H, W]
275
+ gt: torch.Tensor, # [B, 3, H, W]
276
+ ):
277
+ if self.skip_frames > 1:
278
+ pred = pred[::self.skip_frames]
279
+ gt = gt[::self.skip_frames]
280
+ chunk_size = len(pred) if self.chunk_size is None else self.chunk_size
281
+ for pred_chunk, gt_chunk in zip(
282
+ pred.split(chunk_size, dim=0),
283
+ gt.split(chunk_size, dim=0),
284
+ ):
285
+ pred_result = self.gc.calibrate(pred_chunk, camera_model="radial", shared_intrinsics=True)
286
+ gt_result = self.gc.calibrate(gt_chunk, camera_model="radial", shared_intrinsics=True)
287
+
288
+ pred_gravity, gt_gravity = pred_result["gravity"], gt_result["gravity"]
289
+ self.errors["pitch"].update(pitch_error(pred_gravity, gt_gravity))
290
+ self.errors["roll"].update(roll_error(pred_gravity, gt_gravity))
291
+ self.errors["gravity"].update(gravity_error(pred_gravity, gt_gravity))
292
+
293
+ pred_cam, gt_cam = pred_result["camera"], gt_result["camera"]
294
+ self.errors["vfov"].update(vfov_error(pred_cam, gt_cam))
295
+ self.errors["k1"].update(torch.abs(pred_cam.k1 - gt_cam.k1))
296
+ self.errors["k2"].update(torch.abs(pred_cam.k2 - gt_cam.k2))
297
+
298
+ self.errors["latitude"].update(latitude_error(
299
+ pred_result["latitude_field"],
300
+ gt_result["latitude_field"],
301
+ ).mean(axis=(1, 2)))
302
+ self.errors["up"].update(up_error(
303
+ pred_result["up_field"],
304
+ gt_result["up_field"],
305
+ ).mean(axis=(1, 2)))
306
+
307
+ def compute(self):
308
+ return {f"{k}_err": v.compute() for k, v in self.errors.items()}
309
+
310
+ class UcmCameraRayAngularErrorRho(Metric):
311
+ higher_is_better = True
312
+
313
+ def __init__(
314
+ self,
315
+ model_id: str = "lpiccinelli/unik3d-vitl",
316
+ chunk_size: int = 16,
317
+ resolution_level: int = 0,
318
+ ):
319
+ super().__init__()
320
+ self.model = UniK3D.from_pretrained(model_id)
321
+ self.rho = torch.nn.ModuleDict({k: MeanMetric() for k in [
322
+ "gt", "pred"
323
+ ]})
324
+ self.model.resolution_level = resolution_level
325
+ self.chunk_size = chunk_size
326
+
327
+ def update(
328
+ self,
329
+ pred: torch.Tensor, # [B, 3, H, W]
330
+ gt: torch.Tensor, # [B, 3, H, W]
331
+ x_fov: float,
332
+ xi: float,
333
+ ):
334
+ _, _, height, width = pred.shape
335
+ d_cam = ucpe.ucm_unproject_grid_fov(
336
+ x_fov=x_fov,
337
+ xi=xi,
338
+ height=height,
339
+ width=width,
340
+ device=pred.device,
341
+ )
342
+ for pred_chunk, gt_chunk in zip(
343
+ pred.split(self.chunk_size, dim=0),
344
+ gt.split(self.chunk_size, dim=0),
345
+ ):
346
+ pred_result = self.model.infer(pred_chunk)
347
+ rays = pred_result["rays"]
348
+ rays = rearrange(rays, "B C H W -> B H W C")
349
+ d_cams = repeat(d_cam, "... -> B ...", B=rays.shape[0])
350
+ rho_errors = rho(d_cams, rays) # [B]
351
+ self.rho["gt"].update(rho_errors)
352
+
353
+ gt_result = self.model.infer(gt_chunk)
354
+ gt_rays = gt_result["rays"]
355
+ gt_rays = rearrange(gt_rays, "B C H W -> B H W C")
356
+ rho_errors = rho(gt_rays, rays) # [B]
357
+ self.rho["pred"].update(rho_errors)
358
+
359
+ def compute(self):
360
+ return {f"rho_{k}": v.compute() for k, v in self.rho.items()}
361
+
362
+ class FrechetVideoDistance(Metric):
363
+ higher_is_better: bool = False
364
+ full_state_update: bool = False
365
+
366
+ def __init__(
367
+ self,
368
+ crop_center: bool = True,
369
+ batch_size: int = 10,
370
+ ):
371
+ super().__init__()
372
+ self.crop_center = crop_center
373
+ self.batch_size = batch_size
374
+ self.i3d = load_i3d_pretrained()
375
+
376
+ num_features = 400
377
+ mx_num_feats = (num_features, num_features)
378
+ self.add_state("real_features_sum", torch.zeros(num_features).double(), dist_reduce_fx="sum")
379
+ self.add_state("real_features_cov_sum", torch.zeros(mx_num_feats).double(), dist_reduce_fx="sum")
380
+ self.add_state("real_features_num_samples", torch.tensor(0).long(), dist_reduce_fx="sum")
381
+
382
+ self.add_state("fake_features_sum", torch.zeros(num_features).double(), dist_reduce_fx="sum")
383
+ self.add_state("fake_features_cov_sum", torch.zeros(mx_num_feats).double(), dist_reduce_fx="sum")
384
+ self.add_state("fake_features_num_samples", torch.tensor(0).long(), dist_reduce_fx="sum")
385
+
386
+ def update(self, videos: Tensor, real: bool) -> None:
387
+ features = get_fvd_logits(videos, self.i3d, self.device, bs=self.batch_size, crop_center=self.crop_center)
388
+ self.orig_dtype = features.dtype
389
+ features = features.double()
390
+
391
+ if features.dim() == 1:
392
+ features = features.unsqueeze(0)
393
+ if real:
394
+ self.real_features_sum += features.sum(dim=0)
395
+ self.real_features_cov_sum += features.t().mm(features)
396
+ self.real_features_num_samples += videos.shape[0]
397
+ else:
398
+ self.fake_features_sum += features.sum(dim=0)
399
+ self.fake_features_cov_sum += features.t().mm(features)
400
+ self.fake_features_num_samples += videos.shape[0]
401
+
402
+ def compute(self) -> Tensor:
403
+ if self.real_features_num_samples < 2 or self.fake_features_num_samples < 2:
404
+ raise RuntimeError("More than one sample is required for both the real and fake distributed to compute FID")
405
+ mean_real = (self.real_features_sum / self.real_features_num_samples).unsqueeze(0)
406
+ mean_fake = (self.fake_features_sum / self.fake_features_num_samples).unsqueeze(0)
407
+
408
+ cov_real_num = self.real_features_cov_sum - self.real_features_num_samples * mean_real.t().mm(mean_real)
409
+ cov_real = cov_real_num / (self.real_features_num_samples - 1)
410
+ cov_fake_num = self.fake_features_cov_sum - self.fake_features_num_samples * mean_fake.t().mm(mean_fake)
411
+ cov_fake = cov_fake_num / (self.fake_features_num_samples - 1)
412
+ return _compute_fid(mean_real.squeeze(0), cov_real, mean_fake.squeeze(0), cov_fake).to(self.orig_dtype)
413
+
414
+ print("Running video evaluation...")
415
+ tasks = get_path(args)
416
+
417
+ for task, (test_res_path, test_dir) in tasks.items():
418
+ print(f"Evaluating task: {task}")
419
+ dataloader = prepare_dataloader(args, ["video", "result"], test_res_path)
420
+
421
+ image_metrics = {
422
+ "geocalib": GeoCalibError(),
423
+ "rho": UcmCameraRayAngularErrorRho(),
424
+ "lpips": LearnedPerceptualImagePatchSimilarity(
425
+ net_type="vgg",
426
+ normalize=True,
427
+ ),
428
+ "psnr": PeakSignalNoiseRatio(
429
+ data_range=1.,
430
+ dim=(1, 2, 3)
431
+ ),
432
+ "ssim": StructuralSimilarityIndexMeasure(
433
+ data_range=1.,
434
+ ),
435
+ "cs_text": CLIPScore(
436
+ model_name_or_path="zer0int/LongCLIP-L-Diffusers",
437
+ ),
438
+ "cs_image": CLIPScore(),
439
+ }
440
+ image_metrics = {k: v.to(args.test_device) for k, v in image_metrics.items()}
441
+ data_metrics = {
442
+ "fvd_center": FrechetVideoDistance(),
443
+ "fvd": FrechetVideoDistance(
444
+ crop_center=False,
445
+ ),
446
+ "fid": FrechetInceptionDistance(
447
+ normalize=True,
448
+ ),
449
+ "is": InceptionScore(
450
+ normalize=True,
451
+ )
452
+ }
453
+ data_metrics = {k: v.to(args.test_device) for k, v in data_metrics.items()}
454
+
455
+ eval_results = defaultdict(list)
456
+ for data in tqdm(dataloader, desc="Evaluating videos"):
457
+ if "video" in data:
458
+ gt_video = torch.from_numpy(data["video"]).to(args.test_device) # [C, T, H, W]
459
+ gt_video = rearrange(gt_video, "C T H W -> T C H W") # [T, C, H, W]
460
+ gt_video = gt_video / 2. + 0.5 # to [0, 1]
461
+
462
+ video = torch.from_numpy(data["result"]).to(args.test_device) # [C, T, H, W]
463
+ video = rearrange(video, "C T H W -> T C H W") # [T, C, H, W]
464
+ video = video / 2. + 0.5 # to [0, 1]
465
+
466
+ for metric_name, metric in image_metrics.items():
467
+ if metric_name == "geocalib":
468
+ if "video" not in data:
469
+ continue
470
+ metric.update(
471
+ pred=video,
472
+ gt=gt_video,
473
+ )
474
+ elif metric_name == "rho":
475
+ if "video" not in data:
476
+ continue
477
+ metric.update(
478
+ pred=video,
479
+ gt=gt_video,
480
+ x_fov=data["x_fov"],
481
+ xi=data["xi"],
482
+ )
483
+ elif metric_name == "cs_text":
484
+ for pred in video.split(args.test_chunk_size, dim=0):
485
+ pred = pred * 255.0
486
+ metric.update(
487
+ pred.to(torch.uint8),
488
+ [data["caption"]] * len(pred),
489
+ )
490
+ elif metric_name == "cs_image":
491
+ for pred, gt in zip(
492
+ video[:-1].split(args.test_chunk_size, dim=0),
493
+ video[1:].split(args.test_chunk_size, dim=0),
494
+ ):
495
+ pred, gt = pred * 255.0, gt * 255.0
496
+ metric.update(
497
+ pred.to(torch.uint8),
498
+ gt.to(torch.uint8),
499
+ )
500
+ elif metric_name in ["lpips", "psnr", "ssim"]:
501
+ if "video" not in data:
502
+ continue
503
+ for pred, gt in zip(
504
+ video.split(args.test_chunk_size, dim=0),
505
+ gt_video.split(args.test_chunk_size, dim=0),
506
+ ):
507
+ metric.update(
508
+ pred.contiguous(),
509
+ gt
510
+ )
511
+ else:
512
+ raise NotImplementedError(f"Image metric {metric_name} not implemented.")
513
+
514
+ if metric_name in ("geocalib", "rho"):
515
+ results = metric.compute()
516
+ for key, value in results.items():
517
+ eval_results[key].append({
518
+ "video_id": data["video_id"],
519
+ "video_results": value.cpu().item(),
520
+ })
521
+ else:
522
+ eval_results[metric_name].append({
523
+ "video_id": data["video_id"],
524
+ "video_results": metric.compute().cpu().item(),
525
+ })
526
+ metric.reset()
527
+
528
+ for metric_name, metric in data_metrics.items():
529
+ if metric_name == "is":
530
+ for pred in video.split(args.test_chunk_size, dim=0):
531
+ metric.update(pred)
532
+ if "video" not in data:
533
+ continue
534
+ if metric_name == "fid":
535
+ for pred, gt in zip(
536
+ video.split(args.test_chunk_size, dim=0),
537
+ gt_video.split(args.test_chunk_size, dim=0),
538
+ ):
539
+ metric.update(pred, real=False)
540
+ metric.update(gt, real=True)
541
+ elif metric_name in ("fvd", "fvd_center"):
542
+ metric.update(video.unsqueeze(0), real=False)
543
+ metric.update(gt_video.unsqueeze(0), real=True)
544
+
545
+ for metric_name, metric in data_metrics.items():
546
+ if not metric.update_called:
547
+ continue
548
+ if metric_name in ("fid", "fvd", "fvd_center"):
549
+ eval_results[metric_name] = metric.compute().item()
550
+ elif metric_name == "is":
551
+ eval_results[metric_name], eval_results[f"{metric_name}_std"] = metric.compute()
552
+ eval_results[metric_name] = eval_results[metric_name].cpu().item()
553
+ eval_results[f"{metric_name}_std"] = eval_results[f"{metric_name}_std"].cpu().item()
554
+
555
+ save_evaluation(args, test_dir, eval_results, "video_metrics")
556
+
557
+
558
+ def overall(args):
559
+ tasks = get_path(args)
560
+ eval_res_name = "last.json" if args.load_last else f"{args.test_name}_eval_results.json"
561
+ for task, (_, test_dir) in tasks.items():
562
+ overall_res = {}
563
+ for key in ["qalign", "video_metrics", "pose"]:
564
+ eval_res_path = test_dir / key / eval_res_name
565
+ if not eval_res_path.exists():
566
+ print(f"Evaluation results for {key} not found at {eval_res_path}. Skipping.")
567
+ continue
568
+ with open(eval_res_path, "r") as f:
569
+ eval_res = json.load(f)
570
+ for metric, values in eval_res.items():
571
+ overall_res[f"{key}/{metric}"] = values[0]
572
+ overall_res_path = test_dir / "overall" / f"{args.test_name}.json"
573
+ overall_res_path.parent.mkdir(parents=True, exist_ok=True)
574
+ with open(overall_res_path, "w") as f:
575
+ json.dump(overall_res, f, indent=4)
576
+ print(f"Overall evaluation results saved to {overall_res_path}")
577
+ print(json.dumps(overall_res, indent=4))
578
+
579
+ if args.save_last:
580
+ link_last(overall_res_path)
581
+
582
+
583
+ def vipe(args):
584
+ from einops import rearrange, repeat
585
+ import ffmpeg
586
+ import torch.nn.functional as F
587
+ import src.camera_control as ucpe
588
+
589
+ def rectify_ucm_to_pinhole(video, x_fov, xi, max_xfov=100.0):
590
+ """
591
+ UCM video → rectified pinhole video (undistortion)
592
+ Args:
593
+ video: torch.Tensor [T, C, H, W], dtype=float32, range [-1,1]
594
+ x_fov: float, horizontal field of view (deg) in UCM model
595
+ xi: float, UCM mirror parameter
596
+ max_xfov: float, limit effective horizontal FOV (deg)
597
+ Returns:
598
+ rectified: numpy array [T, H, W, 3], uint8, rectified pinhole video
599
+ """
600
+
601
+ T, C, H, W = video.shape
602
+ device = video.device
603
+
604
+ # Normalize input to [0,1]
605
+ video = (video + 1.0) / 2.0
606
+
607
+ # ---------- 1) UCM camera intrinsics ----------
608
+ theta = torch.deg2rad(torch.tensor(x_fov / 2, device=device))
609
+ # Limit maximal horizontal FOV (helps reduce distortion & black edges)
610
+ max_theta = torch.deg2rad(torch.tensor(max_xfov / 2, device=device))
611
+ theta_x = torch.min(theta, max_theta)
612
+
613
+ # ---------- 2) Compute vertical FOV from UCM physical rays ----------
614
+ d_cam = ucpe.ucm_unproject_grid_fov(
615
+ x_fov=x_fov,
616
+ xi=xi,
617
+ height=H,
618
+ width=W,
619
+ device=device,
620
+ ) # [H,W,3]
621
+
622
+ mid_x = W // 2
623
+ verts = d_cam[:, mid_x, :] # sample center column rays
624
+
625
+ # vertical angle wrt Z forward axis
626
+ theta_y_rc = torch.atan2(
627
+ torch.abs(verts[:, 1]), # vertical component (Y)
628
+ verts[:, 2].clamp(min=1e-8) # forward Z
629
+ )
630
+ theta_y_eff = torch.max(theta_y_rc) * 0.98 # avoid edge overflow
631
+
632
+ # ---------- 3) Target pinhole intrinsics ----------
633
+ fx_p = fy_p = torch.max(
634
+ (W * 0.5) / torch.tan(theta_x),
635
+ (H * 0.5) / torch.tan(theta_y_eff)
636
+ )
637
+ cx_p = (W - 1) * 0.5
638
+ cy_p = (H - 1) * 0.5
639
+
640
+ # ✅ pinhole grid coordinates
641
+ u = torch.linspace(0, W - 1, W, device=device)
642
+ v = torch.linspace(0, H - 1, H, device=device)
643
+ uu, vv = torch.meshgrid(u, v, indexing="xy") # [W,H]
644
+
645
+ X = (uu - cx_p) / fx_p
646
+ Y = (vv - cy_p) / fy_p
647
+ Z = torch.ones_like(X)
648
+
649
+ # ---------- 5) Map pinhole rays → UCM pixels ----------
650
+ du, dv = ucpe.project_ucm_points_fov(X, Y, Z, x_fov, xi, H, W)
651
+
652
+ # grid normalize to [-1,1]
653
+ grid_x = 2.0 * (du / (W - 1)) - 1.0
654
+ grid_y = 2.0 * (dv / (H - 1)) - 1.0
655
+
656
+ grid = torch.stack([grid_x, grid_y], dim=-1) # [H,W,2]
657
+ grid = grid.unsqueeze(0).expand(T, -1, -1, -1) # [T,H,W,2]
658
+
659
+ # ---------- 6) Warp ----------
660
+ rectified = F.grid_sample(
661
+ video,
662
+ grid,
663
+ mode="bilinear",
664
+ align_corners=False,
665
+ ).clamp(0, 1)
666
+
667
+ rectified = (rectified * 255.0).byte()
668
+ rectified = rectified.permute(0, 2, 3, 1).contiguous() # [T,H,W,3]
669
+ return rectified.cpu().numpy()
670
+
671
+ print("Running Vipe pose generation...")
672
+ tasks = get_path(args)
673
+
674
+ for task, (test_res_path, test_dir) in tasks.items():
675
+ print(f"Evaluating task: {task}")
676
+ dataloader = prepare_dataloader(args, ["result"], test_res_path)
677
+ rectify_res_path = test_res_path.parent / f"{test_res_path.name}_rectify"
678
+ rectify_res_path.mkdir(parents=True, exist_ok=True)
679
+
680
+ for data in tqdm(dataloader, desc="Exporting videos"):
681
+ video = torch.from_numpy(data["result"]).to(args.test_device) # [C, T, H, W]
682
+ video = rearrange(video, "C T H W -> T C H W") # [T, C, H, W]
683
+ _, _, height, width = video.shape
684
+ x_fov = data["x_fov"]
685
+ xi = data["xi"]
686
+
687
+ rectify_video = rectify_ucm_to_pinhole(video, x_fov, xi)
688
+
689
+ out_file = rectify_res_path / f"{data['video_id']}.mp4"
690
+ process = (
691
+ ffmpeg
692
+ .input('pipe:', format='rawvideo', pix_fmt='rgb24', s=f'{width}x{height}', framerate=data["fps"])
693
+ .output(str(out_file), pix_fmt='yuv420p', vcodec='libx264', r=data["fps"], crf=16, preset='slow')
694
+ .overwrite_output()
695
+ .run_async(pipe_stdin=True, quiet=True)
696
+ )
697
+ process.stdin.write(rectify_video.tobytes())
698
+ process.stdin.close()
699
+ process.wait()
700
+
701
+ vipe_path = test_dir / "vipe"
702
+ cmd = [
703
+ "conda", "run", "-n", "vipe",
704
+ "--no-capture-output",
705
+ "python", "/mnt/pfs/users/zhangchen/panshot/UCPE/thirdparty/vipe/run.py",
706
+ "pipeline=default",
707
+ "streams=raw_mp4_stream",
708
+ f"streams.base_path={rectify_res_path}",
709
+ f"pipeline.output.path={vipe_path}",
710
+ "pipeline.output.save_artifacts=true",
711
+ "pipeline.post.depth_align_model=null",
712
+ ]
713
+ print(f"[CMD] {' '.join(cmd)}")
714
+ subprocess.run(cmd, check=True)
715
+
716
+
717
+ def pose(args):
718
+ from torchmetrics import MeanMetric, Metric
719
+ from einops import rearrange, repeat
720
+
721
+ print("Running pose evaluation...")
722
+ tasks = get_path(args)
723
+
724
+ if not args.evaluate_gt and args.valid_pose_percent < 1.0:
725
+ pose_eval_path = args.data_root / "evaluate" / "pose" / "last.json"
726
+ if not pose_eval_path.exists():
727
+ print(f"GT pose evaluation results not found at {pose_eval_path}. Cannot limit to valid poses.")
728
+ valid_video_ids = None
729
+ else:
730
+ with open(pose_eval_path, "r") as f:
731
+ gt_eval_res = json.load(f)
732
+ cammc = {v["video_id"]: v["video_results"] for v in gt_eval_res["cammc"][1]}
733
+ sorted_videos = sorted(cammc.items(), key=lambda x: x[1])
734
+ sorted_videos = sorted_videos[:int(len(sorted_videos) * args.valid_pose_percent)]
735
+ valid_video_ids = set(v[0] for v in sorted_videos)
736
+ else:
737
+ valid_video_ids = None
738
+
739
+
740
+ def normalize_t(rt):
741
+ # normalize translation by max-norm within the same trajectory
742
+ t = rt[:, :3, 3]
743
+ scale = np.max(np.linalg.norm(t, axis=-1)) + 1e-9
744
+ rt[:, :3, 3] /= scale
745
+ return rt
746
+
747
+ def relative_pose(rt):
748
+ # C2W → relative to first frame
749
+ rel = np.zeros_like(rt)
750
+ rel[0] = np.eye(4)
751
+ inv0 = np.linalg.inv(rt[0])
752
+ rel[1:] = inv0 @ rt[1:]
753
+ return rel
754
+
755
+ def calc_rot_err(r1, r2):
756
+ # r1, r2: (T, 3, 3)
757
+ R = np.matmul(np.transpose(r1, (0,2,1)), r2)
758
+ trace = np.trace(R, axis1=-2, axis2=-1)
759
+ angle = np.arccos(np.clip((trace - 1) / 2, -1, 1)) # (T)
760
+ return np.sum(angle)
761
+
762
+ def calc_trans_err(t1, t2):
763
+ return np.sum(np.linalg.norm(t1 - t2, axis=-1))
764
+
765
+ def calc_cammc(rt1, rt2):
766
+ # flatten camera motion difference
767
+ diff = (rt2 - rt1).reshape(rt1.shape[0], -1)
768
+ return np.sum(np.linalg.norm(diff, axis=-1))
769
+
770
+ for task, (test_res_path, test_dir) in tasks.items():
771
+ print(f"Evaluating task: {task}")
772
+ vipe_path = test_dir / "vipe"
773
+ vipe_pose_path = vipe_path / "pose"
774
+ vipe_video_ids = set(p.stem for p in vipe_pose_path.glob("*.npz"))
775
+ # if valid_video_ids is not None:
776
+ # vipe_video_ids = vipe_video_ids.intersection(valid_video_ids)
777
+ # print(f"Evaluating {len(vipe_video_ids)} valid videos with high-quality GT poses.")
778
+ dataloader = prepare_dataloader(args, ["pose"], video_ids=vipe_video_ids)
779
+
780
+ eval_results = defaultdict(list)
781
+ for data in tqdm(dataloader, desc="Evaluating poses"):
782
+ gt_c2w = data["pose"]
783
+ last_row = repeat(np.array([0,0,0,1], dtype=gt_c2w.dtype), "n -> t 1 n", t=gt_c2w.shape[0])
784
+ gt_c2w = np.concatenate([gt_c2w, last_row], axis=-2) # (T, 4, 4)
785
+
786
+ pred_c2w = np.load(vipe_pose_path / f"{data['video_id']}.npz")["data"] # (T, 4, 4)
787
+
788
+ if args.frame_stride is not None:
789
+ gt_c2w = gt_c2w[::args.frame_stride]
790
+ pred_c2w = pred_c2w[::args.frame_stride]
791
+
792
+ if args.pose_frames is not None:
793
+ gt_c2w = gt_c2w[:args.pose_frames]
794
+ pred_c2w = pred_c2w[:args.pose_frames]
795
+
796
+ # Relative + translation normalized
797
+ gt_rel = normalize_t(relative_pose(gt_c2w.copy()))
798
+ pred_rel = normalize_t(relative_pose(pred_c2w.copy()))
799
+
800
+ # Metrics
801
+ rot_err = calc_rot_err(gt_rel[:, :3, :3], pred_rel[:, :3, :3])
802
+ trans_err = calc_trans_err(gt_rel[:, :3, 3], pred_rel[:, :3, 3])
803
+ cammc = calc_cammc(gt_rel[:, :3, :4], pred_rel[:, :3, :4])
804
+
805
+ results = {
806
+ "rot_err": rot_err,
807
+ "trans_err": trans_err,
808
+ "cammc": cammc,
809
+ }
810
+
811
+ vipe_gt_path = args.data_root / "evaluate" / "vipe" / "pose"
812
+ if not args.evaluate_gt and vipe_gt_path.exists() \
813
+ and valid_video_ids is not None and data["video_id"] in valid_video_ids:
814
+ gt_c2w = np.load(vipe_gt_path / f"{data['video_id']}.npz")["data"] # (T, 4, 4)
815
+ gt_rel = normalize_t(relative_pose(gt_c2w.copy()))
816
+
817
+ # Metrics
818
+ rot_err = calc_rot_err(gt_rel[:, :3, :3], pred_rel[:, :3, :3])
819
+ trans_err = calc_trans_err(gt_rel[:, :3, 3], pred_rel[:, :3, 3])
820
+ cammc = calc_cammc(gt_rel[:, :3, :4], pred_rel[:, :3, :4])
821
+ results.update({
822
+ "rot_err_vipe": rot_err,
823
+ "trans_err_vipe": trans_err,
824
+ "cammc_vipe": cammc,
825
+ })
826
+
827
+ for key, value in results.items():
828
+ eval_results[key].append({
829
+ "video_id": data["video_id"],
830
+ "video_results": float(value),
831
+ })
832
+
833
+ save_evaluation(args, test_dir, eval_results, "pose")
834
+
835
+
836
+ def main():
837
+ args = Args()
838
+
839
+ for step in args.test_steps:
840
+ if args.conda_envs and step in args.conda_envs:
841
+ conda_env = args.conda_envs[step]
842
+ print(f"[INFO] Running step '{step}' in conda env: {conda_env}")
843
+
844
+ # 当前脚本路径
845
+ script_path = Path(__file__).resolve()
846
+ script_path = script_path.relative_to(Path.cwd())
847
+
848
+ # 构造命令:使用 conda run 调用
849
+ cmd = [
850
+ "conda", "run", "-n", conda_env,
851
+ "--no-capture-output",
852
+ "python", str(script_path),
853
+ f"--test_steps=[{step}]", # 只运行该 step
854
+ "--conda_envs={}", # 避免递归调用 conda
855
+ ]
856
+
857
+ # 把其他命令行参数透传下去
858
+ # 注意:Args 使用了 pydantic-settings + tyro 等 CLI 解析工具,
859
+ # 你可以根据需要加上传入的 CLI 参数,这里简化为当前 sys.argv
860
+ extra_args = sys.argv[1:]
861
+ cmd.extend(extra_args)
862
+
863
+ print(f"[CMD] {' '.join(cmd)}")
864
+ subprocess.run(cmd, check=True)
865
+ else:
866
+ globals()[step](args)
867
+
868
+
869
+ if __name__ == "__main__":
870
+ main()
UCPE/src/main.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import lightning as pl
2
+ from lightning.pytorch.cli import LightningCLI
3
+ from lightning.pytorch.loggers import WandbLogger
4
+ from lightning.pytorch.callbacks import ModelCheckpoint, LearningRateMonitor
5
+ from jsonargparse import lazy_instance
6
+ from torch.utils.data import DataLoader
7
+ from pathlib import Path
8
+ import torch
9
+ from diffsynth.pipelines.wan_video_panshot import WanVideoPipeline, ModelConfig
10
+ import wandb
11
+ import os
12
+ from src.dataset import PanShotDataset, Re10kDataset, DemoDataset
13
+ from diffsynth import save_video
14
+ from src.camera_control import patch_dit, enable_grad
15
+ from typing import Literal
16
+ from pytorch_lightning.utilities.rank_zero import rank_zero_only
17
+ import numpy as np
18
+ from tqdm.auto import tqdm
19
+ from typing import Optional
20
+
21
+
22
+ class PanShotDataModule(pl.LightningDataModule):
23
+ def __init__(
24
+ self,
25
+ data_root: Path = Path("data/UCPE"),
26
+ batch_size: int = 1,
27
+ num_workers: int = 4,
28
+ zero_first_yaw: bool = True,
29
+ ):
30
+ super().__init__()
31
+ self.save_hyperparameters()
32
+ self.test_load_keys = ["video", "pose"]
33
+
34
+ def setup(self, stage):
35
+ self.hparams.model_id = self.trainer.model.hparams.model_id
36
+
37
+ def train_dataloader(self):
38
+ dataset = PanShotDataset(self.hparams, split="train", load_keys=["cache", "pose"])
39
+ return DataLoader(dataset, batch_size=self.hparams.batch_size, shuffle=True, num_workers=self.hparams.num_workers)
40
+
41
+ def val_dataloader(self):
42
+ dataset = PanShotDataset(self.hparams, split="test", load_keys=self.test_load_keys)
43
+ return DataLoader(dataset, batch_size=1, shuffle=False, num_workers=self.hparams.num_workers)
44
+
45
+ def test_dataloader(self):
46
+ dataset = PanShotDataset(self.hparams, split="test", load_keys=self.test_load_keys)
47
+ return DataLoader(dataset, batch_size=1, shuffle=False, num_workers=self.hparams.num_workers)
48
+
49
+ def predict_dataloader(self):
50
+ dataset = PanShotDataset(self.hparams, split="test", load_keys=self.test_load_keys)
51
+ return DataLoader(dataset, batch_size=1, shuffle=False, num_workers=self.hparams.num_workers)
52
+
53
+
54
+ class Re10kDataModule(pl.LightningDataModule):
55
+ def __init__(
56
+ self,
57
+ data_root: Path = Path("data/RealEstate10k"),
58
+ batch_size: int = 1,
59
+ num_workers: int = 4,
60
+ overwrite_xfov: float = 100.0,
61
+ ):
62
+ super().__init__()
63
+ self.save_hyperparameters()
64
+
65
+ @rank_zero_only
66
+ def normalize_traj(self, normalization_file):
67
+ tgt_data = PanShotDataModule()
68
+ tgt_data.test_load_keys = ["pose"]
69
+ tgt_dataloader = tgt_data.predict_dataloader()
70
+ src_dataloader = self.predict_dataloader()
71
+ traj_length_mean = []
72
+ for dataloader in (tgt_dataloader, src_dataloader):
73
+ traj_length_sum = 0.
74
+ traj_num = 0
75
+ for data in tqdm(dataloader, desc="Calculating trajectory length"):
76
+ pose = data["pose"] # (B, T, 3, 4)
77
+ traj = pose[..., 3] # (B, T, 3)
78
+ traj_length = torch.sum(torch.linalg.norm(traj[:, 1:] - traj[:, :-1], dim=-1), dim=-1) # (B,)
79
+ traj_length_sum += traj_length.sum().item()
80
+ traj_num += traj.shape[0]
81
+ traj_length_mean.append(traj_length_sum / traj_num)
82
+ normalize_traj = traj_length_mean[0] / traj_length_mean[1]
83
+ print(f"Trajectory length normalization factor: {normalize_traj}")
84
+ Path(normalization_file).parent.mkdir(parents=True, exist_ok=True)
85
+ np.savetxt(normalization_file, np.array([normalize_traj], dtype=np.float32))
86
+
87
+ def setup(self, stage):
88
+ self.hparams.num_frames = self.trainer.model.hparams.num_frames
89
+ normalization_file = Path(self.hparams.data_root) / "traj_normalization.txt"
90
+ if not normalization_file.exists():
91
+ self.normalize_traj(normalization_file)
92
+ self.trainer.strategy.barrier()
93
+ self.hparams.normalize_traj = float(np.loadtxt(normalization_file))
94
+
95
+ def predict_dataloader(self):
96
+ dataset = Re10kDataset(self.hparams, split="test")
97
+ return DataLoader(dataset, batch_size=self.hparams.batch_size, shuffle=False, num_workers=self.hparams.num_workers)
98
+
99
+
100
+ class DemoDataModule(pl.LightningDataModule):
101
+ def __init__(
102
+ self,
103
+ panshot_data_root: Path = Path("data/UCPE"),
104
+ re10k_data_root: Path = Path("data/RealEstate10k"),
105
+ input_file: Path = Path("demo/teaser.json"),
106
+ batch_size: int = 1,
107
+ num_workers: int = 1,
108
+ ):
109
+ super().__init__()
110
+ self.save_hyperparameters()
111
+
112
+ def setup(self, stage):
113
+ self.hparams.num_frames = self.trainer.model.hparams.num_frames
114
+ normalization_file = Path(self.hparams.re10k_data_root) / "traj_normalization.txt"
115
+ self.hparams.re10k_normalize_traj = float(np.loadtxt(normalization_file))
116
+
117
+ def predict_dataloader(self):
118
+ dataset = DemoDataset(self.hparams)
119
+ return DataLoader(dataset, batch_size=self.hparams.batch_size, shuffle=False, num_workers=self.hparams.num_workers)
120
+
121
+
122
+ class PanShotTrainModule(pl.LightningModule):
123
+ def __init__(
124
+ self,
125
+ model_id: str = "Wan-AI/Wan2.1-T2V-1.3B",
126
+ learning_rate: float = 1e-4,
127
+ use_gradient_checkpointing: bool = True,
128
+ use_gradient_checkpointing_offload: bool = False,
129
+ ckpt_path: Path = None,
130
+ fps: int = 16,
131
+ height: int = 480,
132
+ width: int = 832,
133
+ num_frames: int = 81,
134
+ num_inference_steps: int = 50,
135
+ tiled: bool = False,
136
+ camera_condition: str = "relray_absmap",
137
+ adaptation_method: Literal[
138
+ "before",
139
+ "after",
140
+ "parallel",
141
+ ] = "parallel",
142
+ ti2v_input_image_prob: float = 0.5,
143
+ attn_compress: int = 8,
144
+ num_predict: Optional[int] = None,
145
+ ):
146
+ super().__init__()
147
+ file_patterns = [
148
+ "models_t5_umt5-xxl-enc-bf16.pth",
149
+ "diffusion_pytorch_model*.safetensors",
150
+ "Wan2.1_VAE.pth",
151
+ ]
152
+ self.pipe = WanVideoPipeline.from_pretrained(
153
+ torch_dtype=torch.bfloat16,
154
+ device="cpu",
155
+ model_configs=[
156
+ ModelConfig(model_id=model_id, origin_file_pattern=pattern, offload_device="cpu")
157
+ for pattern in file_patterns
158
+ ]
159
+ )
160
+
161
+ keywords = patch_dit(
162
+ self.pipe, camera_condition, height, width,
163
+ attn_compress=attn_compress, adaptation_method=adaptation_method
164
+ )
165
+ enable_grad(self.pipe, keywords)
166
+
167
+ self.strict_loading = False
168
+ if ckpt_path is not None:
169
+ print(f"Loading weights from {ckpt_path}")
170
+ state_dict = torch.load(ckpt_path, map_location="cpu")
171
+ if "state_dict" in state_dict:
172
+ state_dict = state_dict["state_dict"]
173
+ self.load_state_dict(state_dict, strict=False)
174
+
175
+ self.save_hyperparameters()
176
+
177
+ def setup(self, stage=None):
178
+ self.pipe.device = self.device
179
+
180
+ def predict_step(self, batch, batch_idx, dataloader_idx=0):
181
+ for i in range(self.hparams.num_predict or 1):
182
+ video = self(batch, seed=i)
183
+ is_i2v = "input_image" in batch and self.pipe.dit.fuse_vae_embedding_in_latents
184
+ video_folder = "i2v" if is_i2v else "t2v"
185
+ if isinstance(self.trainer.datamodule, PanShotDataModule):
186
+ split = "predict"
187
+ elif isinstance(self.trainer.datamodule, DemoDataModule):
188
+ split = "demo"
189
+ else:
190
+ split = Path(self.trainer.datamodule.hparams.data_root).name
191
+ self.save_output(
192
+ video,
193
+ batch,
194
+ split=split,
195
+ video_folder=video_folder,
196
+ quality=8,
197
+ suffix=f"-{i}" if self.hparams.num_predict else None
198
+ )
199
+ if is_i2v:
200
+ del batch["input_image"]
201
+ self.predict_step(batch, batch_idx, dataloader_idx)
202
+
203
+ def save_output(self, video, batch, split, video_folder, step=None, quality=5, suffix=None):
204
+ video_id = batch["video_id"][0]
205
+
206
+ video_prefix = os.path.join(self.logger.save_dir, split, video_folder, video_id)
207
+ if step is not None:
208
+ video_prefix = f"{video_prefix}-{step}"
209
+ if suffix is not None:
210
+ video_prefix = video_prefix + suffix
211
+ video_path = video_prefix + ".mp4"
212
+ os.makedirs(os.path.dirname(video_path), exist_ok=True)
213
+ save_video(video, video_path, fps=self.hparams.fps, quality=quality)
214
+
215
+ reference_path = os.path.join(self.logger.save_dir, split, "reference", f"{video_id}.mp4")
216
+ os.makedirs(os.path.dirname(reference_path), exist_ok=True)
217
+ if not os.path.exists(reference_path) and "video" in batch:
218
+ reference_video = self.pipe.vae_output_to_video(batch["video"])
219
+ save_video(reference_video, reference_path, fps=self.hparams.fps, quality=quality)
220
+
221
+ caption_path = os.path.join(self.logger.save_dir, split, "caption", f"{video_id}.txt")
222
+ os.makedirs(os.path.dirname(caption_path), exist_ok=True)
223
+ if not os.path.exists(caption_path):
224
+ with open(caption_path, "w") as f:
225
+ f.write(batch["caption"][0])
226
+
227
+ print(f"Saved video to {video_path}")
228
+
229
+ return video_path, reference_path
230
+
231
+ def forward(self, batch, seed=None):
232
+ video = self.pipe(
233
+ prompt=batch["caption"][0],
234
+ input_image=batch.get("input_image", None),
235
+ camera_control_panshot={k: batch[k] for k in ["pose", "xi", "x_fov"]},
236
+ negative_prompt="色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,��止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
237
+ num_inference_steps=self.hparams.num_inference_steps,
238
+ tiled=self.hparams.tiled,
239
+ seed=seed,
240
+ height=self.hparams.height,
241
+ width=self.hparams.width,
242
+ num_frames=self.hparams.num_frames,
243
+ )
244
+ return video
245
+
246
+ def on_fit_start(self):
247
+ if self.trainer.is_global_zero and hasattr(self.logger, "experiment"):
248
+ self.logger.experiment.watch(self, log_graph=False, log_freq=1000)
249
+
250
+ def training_step(self, batch, batch_idx):
251
+ self.pipe.scheduler.set_timesteps(1000, training=True)
252
+
253
+ # Data
254
+ _, _, length, height, width = batch["input_latents"].shape
255
+ num_frames = (length - 1) * 4 + 1
256
+ height = height * self.pipe.vae.upsampling_factor
257
+ width = width * self.pipe.vae.upsampling_factor
258
+ inputs_posi = {}
259
+ inputs_nega = {}
260
+ inputs_shared = {
261
+ "camera_control_panshot": {k: batch[k] for k in ["pose", "xi", "x_fov"]},
262
+ "input_latents": batch["input_latents"],
263
+ "context": batch["context"],
264
+ "height": height,
265
+ "width": width,
266
+ "num_frames": num_frames,
267
+ "cfg_scale": 1,
268
+ "rand_device": self.device,
269
+ "use_gradient_checkpointing": self.hparams.use_gradient_checkpointing,
270
+ "use_gradient_checkpointing_offload": self.hparams.use_gradient_checkpointing_offload,
271
+ "cfg_merge": False,
272
+ }
273
+
274
+ if "first_frame_latents" in batch \
275
+ and self.pipe.dit.fuse_vae_embedding_in_latents \
276
+ and torch.rand(1).item() < self.hparams.ti2v_input_image_prob:
277
+ inputs_shared["first_frame_latents"] = batch["first_frame_latents"]
278
+
279
+ for unit in self.pipe.units:
280
+ inputs_shared, inputs_posi, inputs_nega = self.pipe.unit_runner(unit, self.pipe, inputs_shared, inputs_posi, inputs_nega)
281
+ inputs = {**inputs_shared, **inputs_posi}
282
+
283
+ # Compute loss
284
+ models = {name: getattr(self.pipe, name) for name in self.pipe.in_iteration_models}
285
+ loss = self.pipe.training_loss(**models, **inputs)
286
+
287
+ # Record log
288
+ self.log("train/loss", loss, prog_bar=True)
289
+ return loss
290
+
291
+ @torch.no_grad()
292
+ def validation_step(self, batch, batch_idx):
293
+ video = self(batch, seed=0)
294
+ is_i2v = "input_image" in batch and self.pipe.dit.fuse_vae_embedding_in_latents
295
+ video_folder = "i2v" if is_i2v else "t2v"
296
+ video_path, reference_path = self.save_output(
297
+ video, batch, split="validation", video_folder=video_folder, step=self.global_step)
298
+ log_dict = self.visualize(video_path, reference_path, batch)
299
+ log_dict = {f"val/{k}": v for k, v in log_dict.items()}
300
+ self.logger.experiment.log(log_dict)
301
+ if is_i2v:
302
+ del batch["input_image"]
303
+ self.validation_step(batch, batch_idx)
304
+
305
+ def visualize(self, video_path, reference_path, batch):
306
+ log_dict = {}
307
+ log_dict["video"] = wandb.Video(
308
+ video_path,
309
+ caption=batch["caption"][0],
310
+ format="mp4",
311
+ )
312
+ log_dict["reference"] = wandb.Video(
313
+ reference_path,
314
+ caption=batch["video_id"][0],
315
+ format="mp4",
316
+ )
317
+ return log_dict
318
+
319
+ def configure_optimizers(self):
320
+ trainable_modules = filter(lambda p: p.requires_grad, self.pipe.dit.parameters())
321
+ optimizer = torch.optim.AdamW(trainable_modules, lr=self.hparams.learning_rate)
322
+ return optimizer
323
+
324
+ def on_save_checkpoint(self, checkpoint):
325
+ for key in list(checkpoint["state_dict"].keys()):
326
+ if not key.startswith("pipe.dit."):
327
+ del checkpoint["state_dict"][key]
328
+
329
+
330
+ class MyCLI(LightningCLI):
331
+ def add_arguments_to_parser(self, parser):
332
+ parser.add_lightning_class_args(ModelCheckpoint, "checkpoint")
333
+ parser.set_defaults({
334
+ # "data": "PanShotDataModule",
335
+ "checkpoint.dirpath": os.path.join(self.trainer_defaults["default_root_dir"], "checkpoints"),
336
+ "checkpoint.save_last": True,
337
+ # "checkpoint.every_n_train_steps": 10000,
338
+ # "checkpoint.every_n_epochs": 1,
339
+ })
340
+
341
+
342
+ def main():
343
+ torch.set_float32_matmul_precision('high')
344
+
345
+ wandb_id = os.environ.get("WANDB_RUN_ID", wandb.util.generate_id())
346
+ exp_dir = os.path.join("logs", wandb_id)
347
+ wandb_logger = lazy_instance(
348
+ WandbLogger,
349
+ # entity="pidan1231239",
350
+ project="ucpe",
351
+ id=wandb_id,
352
+ save_dir=exp_dir,
353
+ resume="allow",
354
+ )
355
+
356
+ lr_monitor = LearningRateMonitor(logging_interval="step")
357
+
358
+ cli = MyCLI(
359
+ model_class=PanShotTrainModule,
360
+ # datamodule_class=PanShotDataModule,
361
+ save_config_kwargs={"overwrite": True},
362
+ parser_kwargs={"parser_mode": "omegaconf", "default_env": True},
363
+ seed_everything_default=int(os.environ.get("LOCAL_RANK", 0)),
364
+ trainer_defaults={
365
+ "accelerator": "gpu",
366
+ "devices": "auto",
367
+ "strategy": "deepspeed_stage_1",
368
+ "log_every_n_steps": 10,
369
+ "num_sanity_val_steps": 1,
370
+ "limit_train_batches": 1000,
371
+ "limit_val_batches": 3,
372
+ # "limit_predict_batches": 10,
373
+ "limit_test_batches": 10,
374
+ "benchmark": True,
375
+ "max_epochs": 10,
376
+ # "accumulate_grad_batches": 16,
377
+ "precision": "bf16-true",
378
+ "callbacks": [lr_monitor],
379
+ "logger": wandb_logger,
380
+ "default_root_dir": exp_dir,
381
+ },
382
+
383
+ )
384
+
385
+
386
+ if __name__ == "__main__":
387
+ main()
UCPE/thirdparty/GeoCalib/.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.ipynb linguist-documentation
UCPE/tools/align_panflow.py ADDED
@@ -0,0 +1,599 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import jsonlines
3
+ from tqdm.auto import tqdm
4
+ from collections import defaultdict
5
+ import matplotlib.pyplot as plt
6
+ import ffmpeg
7
+ import json
8
+ import csv
9
+ import numpy as np
10
+ import cv2
11
+ from einops import rearrange, repeat
12
+ from visualize_pose import vis_to_html
13
+ from decord import VideoReader, cpu
14
+ from copy import deepcopy
15
+
16
+
17
+ panflow_root = Path("data/360-1M")
18
+ panshot_root = Path("data/UCPE")
19
+ debug_root = Path("debug/align_panflow")
20
+ debug_root.mkdir(parents=True, exist_ok=True)
21
+
22
+ filter_root = panshot_root / "PanFlow" / "filtered"
23
+ filter_clips_thres = 0.5 # reject if >50% clips are filtered
24
+
25
+ score_jsonl = panshot_root / "PanFlow" / "scores.jsonl"
26
+ qalign_keys = ["image_aesthetic", "image_quality", "video_quality"]
27
+ max_clips_per_video = 10
28
+ max_clips_per_poi = 5
29
+
30
+ split = "train" # "train" or "test"
31
+ camerabench_root = panshot_root / "CameraBench"
32
+ output_root = panshot_root / "PanFlow" / f"align_to_camerabench-{split}"
33
+ output_root.mkdir(parents=True, exist_ok=True)
34
+ summary_root = output_root.parent / f"{output_root.name}-summary"
35
+ summary_root.mkdir(parents=True, exist_ok=True)
36
+ static_words = ["static", "fixed"]
37
+ rotation_score_thres = 5.0 # degrees, reject if rotation_score < threshold
38
+ rotating_clips_thres = 0.5 # reject if >50% clips have large rotation
39
+
40
+ top_k = 30 if split == "train" else 10 # how many matches to keep per 360-1M clip
41
+ target_fps = 16
42
+ match_step = 5 # sweep step in frames
43
+
44
+ visualize_gravity = False
45
+ visualize_pose = False
46
+ visualize_motion = False
47
+ visualize_rotation = False
48
+ visualize_fps = 1.
49
+
50
+ cb_geocalib_file = camerabench_root / "geocalib.jsonl"
51
+ with jsonlines.open(cb_geocalib_file, "r") as reader:
52
+ cb_geocalib = {obj["video"]: obj for obj in reader}
53
+
54
+ # 读取 CameraBench 的位姿数据
55
+ cb_meta_file = camerabench_root / f"processed_{split}.jsonl"
56
+ with jsonlines.open(cb_meta_file, "r") as reader:
57
+ cb_meta_all = list(reader)
58
+ cb_R_gravity = []
59
+ cb_poses = []
60
+ cb_meta = []
61
+ for obj in tqdm(cb_meta_all, desc="Loading CameraBench poses"):
62
+ obj["video"] = Path(obj["path"]).stem
63
+ video_id = obj["video"]
64
+ camera_caption = obj["caption"].lower()
65
+ if any(word in camera_caption for word in static_words):
66
+ tqdm.write(f"Skipping static camera {video_id} ({camera_caption})")
67
+ continue
68
+
69
+ pose_file = camerabench_root / "vipe" / "pose" / f"{video_id}.npz"
70
+ if not pose_file.exists():
71
+ tqdm.write(f"Pose file not found: {pose_file}, skipping.")
72
+ continue
73
+
74
+ cb_meta.append(obj)
75
+ pose = np.load(pose_file)["data"] # (T, 4, 4)
76
+ cb_poses.append(pose)
77
+ cb_R_gravity.append(cb_geocalib[video_id]["R"])
78
+
79
+ print(f"Loaded {len(cb_poses)} / {len(cb_meta_all)} CameraBench poses.")
80
+ cb_poses = np.array(cb_poses) # (N, T, 4, 4)
81
+ cb_R_gravity = np.array(cb_R_gravity) # (N, 3, 3)
82
+
83
+ cb_w2c0 = np.linalg.inv(cb_poses[:, 0]) # (N, 4, 4) batched inv
84
+ cb_poses_origin = cb_w2c0[:, None] @ cb_poses # (N, T, 4, 4) 第一帧在原点
85
+
86
+ # rotate cb_poses based on cb_R to world align with gravity direction
87
+ cb_T_gravity = repeat(np.eye(4), 'h w -> n h w', n=cb_R_gravity.shape[0]) # (N,4,4)
88
+ cb_T_gravity[:, :3, :3] = cb_R_gravity
89
+ cb_poses_gravity = cb_T_gravity[:, None, :, :] @ cb_poses_origin
90
+
91
+ # save cb_poses_gravity
92
+ cb_pose_root = camerabench_root / "pose"
93
+ cb_pose_root.mkdir(parents=True, exist_ok=True)
94
+ for i, obj in enumerate(cb_meta):
95
+ cb_pose_dir = cb_pose_root / f"{obj['video']}.npy"
96
+ np.save(cb_pose_dir, cb_poses_gravity[i])
97
+
98
+ cb_pos = cb_poses_gravity[:, :, :3, 3] # (N, T, 3)
99
+ target_frames = cb_pos.shape[1]
100
+
101
+ if visualize_gravity:
102
+ for i, obj in enumerate(cb_meta[:3]):
103
+ video_id = obj["video"]
104
+ cb_pose_dir = debug_root / "gravity" / video_id
105
+ cb_pose_dir.mkdir(parents=True, exist_ok=True)
106
+ origin_pose_file = cb_pose_dir / "origin.npy"
107
+ np.save(origin_pose_file, cb_poses_gravity[i])
108
+ gravity_pose_file = cb_pose_dir / "gravity_aligned.npy"
109
+ np.save(gravity_pose_file, cb_poses_origin[i])
110
+ combined_file = cb_pose_dir / "comparison.npy"
111
+ combined_pose = np.concatenate([cb_poses_gravity[i], cb_poses_origin[i]], axis=0)
112
+ np.save(combined_file, combined_pose)
113
+ # vis_to_html(cb_pose_dir, [origin_pose_file, gravity_pose_file])
114
+ vis_to_html(cb_pose_dir, [combined_file])
115
+
116
+ # 读取 PanFlow 的质量分数
117
+ with jsonlines.open(score_jsonl, "r") as reader:
118
+ qalign_scores = {
119
+ (obj["video_id"], obj["clip_id"]): {k: obj[k] for k in qalign_keys}
120
+ for obj in reader
121
+ }
122
+
123
+
124
+ def get_traj_align(A, B, allow_scale=True, eps=1e-12):
125
+ """
126
+ 计算将轨迹 B 对齐到轨迹 A 的相似变换 (R, s),
127
+ 但 R 被约束为仅绕世界 y 轴的旋转(偏航)。
128
+ 假设所有轨迹的第一帧已在原点。
129
+ A: (N1, T, 3)
130
+ B: (N2, T, 3)
131
+ Returns:
132
+ R: (N1, N2, 3, 3) 使得 A ≈ s * R * B
133
+ s: (N1, N2)
134
+ """
135
+ N1, T, _ = A.shape
136
+ N2 = B.shape[0]
137
+
138
+ # 扩维到配对形状 (N1, N2, T, 3)
139
+ A_rel = A[:, None, :, :] # (N1, N2, T, 3)
140
+ B_rel = B[None, :, :, :] # (N1, N2, T, 3)
141
+
142
+ # 仅取 x,z 分量:[..., 0] 为 x,[..., 2] 为 z
143
+ Ax = A_rel[..., 0] # (N1, N2, T)
144
+ Az = A_rel[..., 2]
145
+ Bx = B_rel[..., 0]
146
+ Bz = B_rel[..., 2]
147
+
148
+ # 计算 H_xz 的四个元素(按时间平均)
149
+ h11 = np.einsum("nmt,nmt->nm", Ax, Bx) / T
150
+ h12 = np.einsum("nmt,nmt->nm", Ax, Bz) / T
151
+ h21 = np.einsum("nmt,nmt->nm", Az, Bx) / T
152
+ h22 = np.einsum("nmt,nmt->nm", Az, Bz) / T
153
+
154
+ # 最优偏航角 theta(只绕 y 轴)
155
+ theta = np.arctan2(h12 - h21, h11 + h22) # (N1, N2)
156
+
157
+ c = np.cos(theta)
158
+ s_th = np.sin(theta)
159
+
160
+ # 组装 3x3 的绕 y 轴旋转矩阵
161
+ R = np.zeros((N1, N2, 3, 3), dtype=A.dtype)
162
+ R[..., 0, 0] = c
163
+ R[..., 0, 2] = s_th
164
+ R[..., 1, 1] = 1.0
165
+ R[..., 2, 0] = -s_th
166
+ R[..., 2, 2] = c
167
+
168
+ # 尺度:只用 xz 平面的能量(与 yaw-only 一致)
169
+ if allow_scale:
170
+ var_B_xz = (np.sum(Bx**2 + Bz**2, axis=2) / T) + eps # (N1, N2) 通过 broadcast
171
+ # tr(R2D*H_xz) = c*(h11+h22) + s*(h21 - h12)
172
+ numer = c * (h11 + h22) + s_th * (h21 - h12)
173
+ s = numer / var_B_xz
174
+ else:
175
+ s = np.ones((N1, N2), dtype=A.dtype)
176
+
177
+ return R, s
178
+
179
+
180
+ def apply_traj_align(B, R, s):
181
+ """
182
+ 应用 (R, s) 到轨迹 B,使其对齐到 A。
183
+ B: (N2, T, 3)
184
+ R: (N1, N2, 3, 3)
185
+ s: (N1, N2)
186
+ return: (N1, N2, T, 3)
187
+ """
188
+ # (N1,N2,3,3) @ (N2,T,3) -> (N1,N2,T,3)
189
+ rotated = np.einsum("nmij,mtj->nmti", R, B)
190
+ return s[..., None, None] * rotated
191
+
192
+
193
+ def apply_pose_align(c2w, R, s):
194
+ """
195
+ 应用 (R, s) 到 c2w 外参,左乘 R,平移乘 s:
196
+ R'_cw = R R_cw, t'_cw = s R t_cw
197
+ c2w: (N2,T,4,4) 或 (T,4,4)
198
+ R: (N1,N2,3,3), s: (N1,N2)
199
+ return: (N1,N2,T,4,4)
200
+ """
201
+ if c2w.ndim == 3: # (T,4,4) -> (1,T,4,4)
202
+ c2w = c2w[None, ...]
203
+ N2, T, _, _ = c2w.shape
204
+
205
+ Rc2w = c2w[..., :3, :3] # (N2,T,3,3)
206
+ tc2w = c2w[..., :3, 3] # (N2,T,3)
207
+
208
+ Rc2w_new = np.einsum("nmij,mtjk->nmtik", R, Rc2w) # (N1,N2,T,3,3)
209
+ tc2w_new = s[..., None, None] * np.einsum("nmij,mtj->nmti", R, tc2w) # (N1,N2,T,3)
210
+
211
+ c2w_aligned = np.zeros((R.shape[0], N2, T, 4, 4), dtype=c2w.dtype)
212
+ c2w_aligned[..., :3, :3] = Rc2w_new
213
+ c2w_aligned[..., :3, 3] = tc2w_new
214
+ c2w_aligned[..., 3, 3] = 1.0
215
+ return c2w_aligned
216
+
217
+
218
+ def compute_rmse(A, B_aligned):
219
+ """
220
+ 计算对齐后轨迹之间的 RMSE (Root Mean Square Error)。
221
+
222
+ Args:
223
+ A: (N1, T, 3) # 目标轨迹
224
+ B_aligned: (N1, N2, T, 3) # 已对齐到 A 的轨迹
225
+
226
+ Returns:
227
+ rmse: (N1, N2) # 每对 (A_i, B_j) 的误差
228
+ """
229
+ if A.ndim == 2: # 单条轨迹
230
+ A = A[None, ...]
231
+
232
+ # 误差 (N1,N2,T)
233
+ diff = A[:, None, :, :] - B_aligned
234
+ sqerr = np.sum(diff**2, axis=-1) # (N1,N2,T)
235
+ mse = np.mean(sqerr, axis=-1) # (N1,N2)
236
+ rmse = np.sqrt(mse)
237
+
238
+ return rmse
239
+
240
+
241
+ def traj_length(traj):
242
+ """
243
+ traj: (..., T, 3)
244
+ return: (...,), 每条轨迹路径长度
245
+ """
246
+ diffs = traj[..., 1:] - traj[..., :-1] # (..., T-1, 3)
247
+ lengths = np.linalg.norm(diffs, axis=-1).sum(axis=-1) # (...)
248
+ return lengths
249
+
250
+
251
+ def normalize_traj(traj, eps=1e-8):
252
+ """
253
+ traj: (..., T, 3)
254
+ return: (..., T, 3), 每条轨迹路径长度归一化
255
+ """
256
+ return traj / (traj_length(traj)[..., None, None] + eps)
257
+
258
+
259
+ def visualize_clip(video_file, frames, out_clip_file):
260
+ vr = VideoReader(str(video_file), ctx=cpu(0), num_threads=1)
261
+ start_frame, end_frame = frames
262
+ sample_frames = np.arange(start_frame, end_frame, fps / visualize_fps)
263
+ sample_frames = np.round(sample_frames).astype(int)
264
+ clip_data = vr.get_batch(sample_frames).asnumpy()
265
+
266
+ out_clip_file.parent.mkdir(parents=True, exist_ok=True)
267
+ process = (
268
+ ffmpeg
269
+ .input("pipe:", format="rawvideo", pix_fmt="rgb24", s=f"{clip_data.shape[2]}x{clip_data.shape[1]}", framerate=visualize_fps)
270
+ .output(str(out_clip_file), pix_fmt="yuv420p", vcodec="libx264", r=visualize_fps, crf=23, preset="medium")
271
+ .overwrite_output()
272
+ .run_async(pipe_stdin=True, quiet=True)
273
+ )
274
+ process.stdin.write(clip_data.tobytes())
275
+ process.stdin.close()
276
+ process.wait()
277
+
278
+
279
+ def max_rot_from_anchor(rot_seq, degrees=True, robust_percentile=None):
280
+ """
281
+ 计算一个相机姿态序列中相对于首帧的最大旋转角。
282
+
283
+ Args:
284
+ rot_seq: (T,3,3) 单个clip的旋转矩阵序列
285
+ degrees: True=返回角度, False=弧度
286
+ robust_percentile: 如果指定, 用分位数而不是max
287
+
288
+ Returns:
289
+ float: 最大(或分位数)旋转角
290
+ """
291
+ T = rot_seq.shape[0]
292
+ R0 = rot_seq[0]
293
+ # 相对旋转: R0^T @ Rt
294
+ R_rel = R0.T @ rot_seq # (3,3)@(T,3,3) -> (T,3,3)
295
+ # np.matmul 自动广播: (3,3)@(T,3,3)不可直接,需要einsum
296
+ R_rel = np.einsum('ij,tjk->tik', R0.T, rot_seq)
297
+
298
+ trace = np.trace(R_rel, axis1=-2, axis2=-1)
299
+ cos_theta = np.clip((trace - 1.0) / 2.0, -1.0, 1.0)
300
+ theta = np.arccos(cos_theta) # 弧度, shape (T,)
301
+ theta = theta[1:] # 去掉首帧
302
+
303
+ if robust_percentile is None:
304
+ val = np.max(theta)
305
+ else:
306
+ val = np.percentile(theta, robust_percentile)
307
+ if degrees:
308
+ val = np.degrees(val)
309
+ return val
310
+
311
+
312
+ # 读取 360-1M 的位姿数据
313
+ pf_meta_root = panflow_root / "meta"
314
+ pf_meta_files = list((pf_meta_root).glob("*.json"))
315
+ pf_meta_files.sort()
316
+ print(f"Found {len(pf_meta_files)} PanFlow meta files.")
317
+ # pf_meta_files = pf_meta_files[:100]
318
+
319
+ filter_summary = defaultdict(lambda: 0)
320
+ camera_summary = defaultdict(lambda: 0)
321
+ for meta_file in tqdm(pf_meta_files, desc="Matching 360-1M poses"):
322
+ # tqdm.write(f"Processing {meta_file}")
323
+
324
+ with open(meta_file, "r") as f:
325
+ pf_meta = json.load(f)
326
+ if "slam_clips" not in pf_meta:
327
+ tqdm.write(f"No slam_clips in {meta_file}, skipping.")
328
+ continue
329
+
330
+ filter_file = filter_root / meta_file.name
331
+ if not filter_file.exists():
332
+ tqdm.write(f"Filter file not found: {filter_file}, skipping.")
333
+ continue
334
+ with open(filter_file, "r") as f:
335
+ filter_meta = json.load(f)
336
+ if not filter_meta:
337
+ tqdm.write(f"Empty filter file: {filter_file}, skipping.")
338
+ continue
339
+ reject_clips = [any(clip["filter"].values()) for clip in filter_meta]
340
+ reject_ratio = np.mean(reject_clips)
341
+ if reject_ratio > filter_clips_thres:
342
+ tqdm.write(f"Reject video {meta_file} due to too many ({reject_ratio:.2%}) filtered clips.")
343
+ filter_summary["filter"] += len(pf_meta["slam_clips"]["clips"])
344
+ continue
345
+
346
+ video_id = meta_file.stem
347
+ video_file = panflow_root / "videos" / video_id
348
+ video_file = video_file.with_suffix(".mp4")
349
+ cap = cv2.VideoCapture(str(video_file))
350
+ if not cap.isOpened():
351
+ tqdm.write(f"Failed to open video: {video_file}, skipping.")
352
+ continue
353
+ fps = cap.get(cv2.CAP_PROP_FPS)
354
+
355
+ if "motion_score" not in pf_meta:
356
+ tqdm.write(f"No motion_score in {meta_file}, skipping.")
357
+ continue
358
+
359
+ if "watermark_score" not in pf_meta:
360
+ tqdm.write(f"No watermark_score in {meta_file}, skipping.")
361
+ continue
362
+
363
+ clips = deepcopy(pf_meta["slam_clips"]["clips"])
364
+ missing_qalign = 0
365
+ for clip, slam_pose, motion_score, watermark_score in zip(
366
+ clips,
367
+ pf_meta["slam_pose"]["clips"],
368
+ pf_meta["motion_score"]["clips"],
369
+ pf_meta["watermark_score"]["clips"],
370
+ ):
371
+ clip["scores"] = {}
372
+ clip["scores"]["motion_score"] = motion_score["score"]
373
+ clip["scores"]["watermark_score"] = watermark_score["score"]
374
+ clip["info"] = slam_pose["info"]
375
+ if (video_id, clip["clip_id"]) in qalign_scores:
376
+ scores = qalign_scores[(video_id, clip["clip_id"])]
377
+ clip["scores"] |= scores
378
+ clip["scores"]["avg_qalign"] = float(np.mean(list(scores.values())))
379
+ else:
380
+ clip["scores"]["avg_qalign"] = -1
381
+ missing_qalign += 1
382
+ if missing_qalign > 0:
383
+ tqdm.write(f"Warning: {missing_qalign} / {len(clips)} clips missing q-align scores in {video_id}.")
384
+ clips.sort(key=lambda x: x["scores"]["avg_qalign"], reverse=True)
385
+
386
+ ps_meta = {
387
+ "fps": fps,
388
+ "clips": [],
389
+ }
390
+ poi_counter = defaultdict(lambda: 0)
391
+ rotation_counter = 0
392
+ for i_clip, clip in enumerate(tqdm(clips, desc="Processing clips", leave=False)):
393
+ if len(ps_meta["clips"]) >= max_clips_per_video:
394
+ tqdm.write(f"Reached max clips per video ({max_clips_per_video}), stopping.")
395
+ filter_summary["max_clips_per_video"] += len(clips) - i_clip
396
+ break
397
+
398
+ clip_name = clip["clip_name"]
399
+ clip_id = clip["clip_id"]
400
+
401
+ clip_filter = filter_meta[clip_id - 1]
402
+ assert clip_filter["clip_id"] == clip_id
403
+ if any(clip_filter["filter"].values()):
404
+ reasons = [k for k, v in clip_filter["filter"].items() if v]
405
+ # tqdm.write(f"Clip {video_id}/{clip_name} filtered due to {reasons}, skipping.")
406
+ filter_summary["filter"] += 1
407
+ continue
408
+
409
+ poi_category = clip_filter["poi_category"]
410
+ if all(poi_counter[c] >= max_clips_per_poi for c in poi_category):
411
+ # tqdm.write(f"Clip {video_id}/{clip_name} skipped due to max clips per POI {poi_category}.")
412
+ filter_summary["max_clips_per_poi"] += 1
413
+ continue
414
+
415
+ slam_info = clip["info"]
416
+ motion_score = clip["scores"]["motion_score"]
417
+ frames = clip["frames"]
418
+ clip_dict = {
419
+ "video_id": video_id,
420
+ "clip_id": clip_id,
421
+ "clip_name": clip_name,
422
+ "frames": frames,
423
+ "scores": clip["scores"].copy(),
424
+ "poi_category": poi_category,
425
+ "slam_info": slam_info,
426
+ }
427
+
428
+ num_frames = frames[-1] - frames[0] + 1
429
+ num_frames_sampled = int(round(num_frames / fps * target_fps))
430
+ if num_frames_sampled < target_frames:
431
+ # tqdm.write(f"Clip {video_id}/{clip_name} too short ({num_frames_sampled} < {target_frames}), skipping.")
432
+ filter_summary["too_short"] += 1
433
+ continue
434
+
435
+ if slam_info == "Small camera motion":
436
+ ps_meta["clips"].append(clip_dict)
437
+ if visualize_motion:
438
+ out_clip_file = debug_root / "static_clips" / f"{motion_score:.4f}-{video_id}-{clip_name}.mp4"
439
+ visualize_clip(video_file, frames, out_clip_file)
440
+ filter_summary["small_camera_motion"] += 1
441
+ continue
442
+
443
+ if visualize_motion:
444
+ continue # 只保留静止片段
445
+
446
+ if slam_info != "Success":
447
+ # tqdm.write(f"Clip {video_id}/{clip_name} SLAM not successful ({slam_info}), skipping.")
448
+ filter_summary["slam_fail"] += 1
449
+ continue
450
+
451
+ pose_file = panflow_root / "slam_pose" / video_id / clip_name
452
+ pose_file = pose_file.with_suffix(".npy")
453
+ if not pose_file.exists():
454
+ tqdm.write(f"Pose file not found: {pose_file}, skipping.")
455
+ continue
456
+ pf_pose = np.load(pose_file) # (T, 3, 4)
457
+
458
+ rot_seg = pf_pose[:, :3, :3] # (num_segs,T,3,3)
459
+ rotation_score = max_rot_from_anchor(rot_seg, degrees=True, robust_percentile=95) # (num_segs,)
460
+ rotation_score = float(rotation_score)
461
+ if visualize_rotation:
462
+ out_clip_file = debug_root / "rotation_score" / f"{rotation_score:.4f}-{video_id}-{clip_name}.mp4"
463
+ visualize_clip(video_file, frames, out_clip_file)
464
+ if rotation_score > rotation_score_thres:
465
+ # tqdm.write(f"Clip {video_id}/{clip_name} rejected due to large rotation ({rotation_score:.2f}° > {rotation_score_thres}°), skipping.")
466
+ rotation_counter += 1
467
+ filter_summary["large_rotation"] += 1
468
+ continue
469
+ clip_dict["scores"]["rotation_score"] = rotation_score
470
+
471
+ sample_frames = np.linspace(0, num_frames - 1, num_frames_sampled)
472
+ sample_frames = np.round(sample_frames).astype(int)
473
+
474
+ # 1. 提取所有片段索引
475
+ max_start = num_frames_sampled - target_frames
476
+ num_segs = max_start // match_step + 1
477
+ starts = np.arange(0, max_start+1, match_step)
478
+ num_segs = len(starts)
479
+ idx = starts[:, None] + np.arange(target_frames)[None, :]
480
+ idx = sample_frames[idx]
481
+
482
+ # 2. 取出 c2w
483
+ c2w = pf_pose[idx] # (num_segs, T, 3, 4) 或 (num_segs, T, 4, 4)
484
+ if c2w.shape[-2:] == (3, 4):
485
+ # 补成 4x4
486
+ last_row = repeat(np.array([0,0,0,1], dtype=c2w.dtype), "n -> s t 1 n", s=c2w.shape[0], t=c2w.shape[1])
487
+ c2w = np.concatenate([c2w, last_row], axis=-2) # (num_segs,T,4,4)
488
+
489
+ # 3. 每段归一化到第一帧
490
+ w2c0 = np.linalg.inv(c2w[:, 0]) # (num_segs,4,4)
491
+ c2w = w2c0[:, None] @ c2w # (num_segs,T,4,4)
492
+
493
+ # 4. 提取相机中心轨迹 (num_segs, T, 3)
494
+ pos_seg = c2w[:, :, :3, 3]
495
+
496
+ # 5. 批量对齐 + RMSE
497
+ # pos_seg -> (num_segs,T,3),扩展成 (num_segs,1,T,3),与 cb_pos (N_cb,T,3) 对齐
498
+ R, s = get_traj_align(cb_pos, pos_seg) # R:(N_cb,num_segs,3,3), s:(N_cb,num_segs)
499
+ pos_aligned = apply_traj_align(pos_seg, R, s) # (N_cb,num_segs,T,3)
500
+ rmse = compute_rmse(normalize_traj(cb_pos), normalize_traj(pos_aligned)) # (N_cb,num_segs)
501
+
502
+ best_idx = np.argmin(rmse, axis=1) # (N_cb,)
503
+ best_seg = idx[best_idx] # (N_cb, T)
504
+ best_rmse = rmse[np.arange(len(cb_pos)), best_idx] # (N_cb,)
505
+
506
+ topk_cb = np.argsort(best_rmse)[:top_k]
507
+
508
+ if visualize_pose:
509
+ pose_aligned = apply_pose_align(c2w, R, s) # (N_cb,num_segs,T,4,4)
510
+
511
+ clip_dict["matches"] = []
512
+ for i in topk_cb:
513
+ j = best_idx[i]
514
+ cb_name = cb_meta[i]["video"]
515
+ if cb_meta[i].get("camera_labels", False):
516
+ for label in cb_meta[i]["camera_labels"]:
517
+ camera_summary[label] += 1
518
+ else:
519
+ camera_summary[cb_name] += 1
520
+ clip_dict["matches"].append({
521
+ "video": cb_name,
522
+ "frames": (int(best_seg[i, 0]), int(best_seg[i, -1])),
523
+ "rmse": float(rmse[i, j]),
524
+ "R": R[i, j].tolist(),
525
+ "s": float(s[i, j]),
526
+ })
527
+
528
+ if visualize_pose:
529
+ cb_pose_dir = debug_root / video_id / clip_name / cb_name
530
+ cb_pose_dir.mkdir(parents=True, exist_ok=True)
531
+ cb_pose_file = cb_pose_dir / "target.npy"
532
+ np.save(cb_pose_file, cb_poses_gravity[i])
533
+ pf_pose_file = cb_pose_dir / "aligned.npy"
534
+ np.save(pf_pose_file, pose_aligned[i, j])
535
+ combined_file = cb_pose_dir / "comparison.npy"
536
+ combined_pose = np.concatenate([cb_poses_gravity[i], pose_aligned[i, j]], axis=0)
537
+ np.save(combined_file, combined_pose)
538
+ # vis_to_html(cb_pose_dir, [cb_pose_file, pf_pose_file])
539
+ vis_to_html(cb_pose_dir, [combined_file])
540
+
541
+ for c in poi_category:
542
+ poi_counter[c] += 1
543
+ ps_meta["clips"].append(clip_dict)
544
+
545
+ if not ps_meta["clips"]:
546
+ tqdm.write(f"No valid clips in {meta_file}, skipping.")
547
+ continue
548
+
549
+ if rotation_counter / (len(clips) + rotation_counter) > rotating_clips_thres:
550
+ tqdm.write(f"Reject video {meta_file} due to too many ({rotation_counter}/{len(clips)}) high-rotation clips.")
551
+ filter_summary["large_rotation"] += len(ps_meta["clips"])
552
+ continue
553
+
554
+ filter_summary["success"] += len(ps_meta["clips"])
555
+ out_file = output_root / f"{video_id}.json"
556
+ with open(out_file, "w") as f:
557
+ json.dump(ps_meta, f, indent=4)
558
+
559
+ for name, summary in [
560
+ ("filter_summary", filter_summary),
561
+ ("camera_summary", camera_summary),
562
+ ]:
563
+ summary_file = summary_root / f"{name}.json"
564
+ with open(summary_file, "w") as f:
565
+ json.dump(summary, f, indent=4)
566
+ print(f"Wrote summary to {summary_file}")
567
+
568
+ # 可视化统计结果
569
+ fig, ax = plt.subplots(figsize=(10, 6))
570
+ items = sorted(camera_summary.items(), key=lambda x: x[1], reverse=True)
571
+ values = list(camera_summary.values())
572
+ ax.hist(values, bins='auto', color="skyblue", edgecolor="black")
573
+
574
+ ax.set_xlabel("Match Count")
575
+ ax.set_ylabel("Number of Videos")
576
+ ax.set_title(f"CameraBench Match Distribution")
577
+
578
+ plt.tight_layout()
579
+ summary_file = summary_root / "camera_summary.png"
580
+ fig.savefig(summary_file, dpi=300, bbox_inches="tight")
581
+ plt.close(fig)
582
+ print(f"Saved camera summary histogram to {summary_file}")
583
+
584
+ # 按数量从大到小排序
585
+ items = sorted(filter_summary.items(), key=lambda x: x[1], reverse=True)
586
+ labels, counts = zip(*items)
587
+
588
+ fig, ax = plt.subplots(figsize=(10, 6))
589
+ # 建议用水平条形图,长标签也容易展示
590
+ ax.barh(labels, counts, color="salmon")
591
+ ax.invert_yaxis() # 让数量最多的排在最上
592
+ ax.set_xlabel("Number of filtered clips")
593
+ ax.set_title("Filter Summary")
594
+
595
+ plt.tight_layout()
596
+ summary_file = summary_root / "filter_summary.png"
597
+ fig.savefig(summary_file, dpi=300, bbox_inches="tight")
598
+ plt.close(fig)
599
+ print(f"Saved filter summary plot to {summary_file}")
UCPE/tools/caption_camerabench.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from vllm import LLM, SamplingParams
2
+
3
+ from pathlib import Path
4
+ import jsonlines
5
+ from tqdm.auto import tqdm
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ import os
8
+ import json
9
+ import re
10
+
11
+ from transformers import AutoProcessor
12
+ from qwen_vl_utils import process_vision_info
13
+
14
+
15
+ # -----------------------------
16
+ # basic configuration
17
+ # -----------------------------
18
+ data_root = Path("data/UCPE/CameraBench")
19
+ split = "train" # "train" or "test"
20
+ output_jsonl = data_root / f"captioned_{split}.jsonl"
21
+ meta_file = data_root / f"processed_{split}.jsonl"
22
+
23
+ model_id = "Qwen/Qwen2.5-VL-7B-Instruct" # try 7B first; switch to 32B if resources allow
24
+ # model_id = "chancharikm/qwen2.5-vl-7b-cam-motion-preview"
25
+ nframes = 32 # hint for frame sampling inside qwen_vl_utils
26
+ fps_hint = None # None or a small integer like 1/2/4 (optional)
27
+ batch_size = 8 # how many videos per vLLM.generate batch
28
+ max_workers = min(8, os.cpu_count() or 4) # 线程数按机器调整
29
+ inflight_limit = batch_size * 2 # 同时在制的样本上限
30
+ print(f"Using max_workers={max_workers}, inflight_limit={inflight_limit}")
31
+
32
+
33
+ max_new_tokens = 512
34
+ temperature = 0.2
35
+ top_p = 0.9
36
+ repetition_penalty = 1.05
37
+ gpu_memory_utilization = 0.9
38
+ tensor_parallel_size = 1
39
+ limit_mm_per_prompt = {"video": 1}
40
+
41
+
42
+ def build_llm_input(video_path: Path, prompt_text: str, processor: AutoProcessor):
43
+ # compose messages (system + user text + video item)
44
+ video_item = {"type": "video", "video": str(video_path), "nframes": nframes}
45
+ if fps_hint is not None:
46
+ video_item["fps"] = fps_hint
47
+
48
+ messages = [
49
+ {"role": "system", "content": "You are a helpful video captioning assistant."},
50
+ {"role": "user", "content": [
51
+ {"type": "text", "text": prompt_text},
52
+ video_item
53
+ ]}
54
+ ]
55
+
56
+ # extract frames / prepare tensors for the model (CPU/I/O-heavy)
57
+ image_inputs, video_inputs = process_vision_info(messages)
58
+
59
+ # text template → prompt string
60
+ prompt = processor.apply_chat_template(
61
+ messages, tokenize=False, add_generation_prompt=True
62
+ )
63
+
64
+ mm_data = {}
65
+ if video_inputs is not None:
66
+ mm_data["video"] = video_inputs
67
+
68
+ return {"prompt": prompt, "multi_modal_data": mm_data}
69
+
70
+
71
+ def prepare_one(obj, processor: AutoProcessor):
72
+ """单样本:基于 labels/seed_caption 构造提示 → 抽帧预处理 → 组装 vLLM 输入"""
73
+ vpath = data_root / obj["path"]
74
+ prompt_text = "Please describe this video in detail."
75
+ llm_in = build_llm_input(vpath, prompt_text, processor)
76
+ return obj, llm_in
77
+
78
+
79
+ # -----------------------------
80
+ # load metadata
81
+ # -----------------------------
82
+ with jsonlines.open(meta_file, "r") as reader:
83
+ metadata = list(reader)
84
+ for obj in tqdm(metadata, desc="checking files"):
85
+ assert (data_root / obj["path"]).exists(), f"File not found: {obj['path']}"
86
+
87
+
88
+ # -----------------------------
89
+ # init vLLM + processor
90
+ # -----------------------------
91
+ llm = LLM(
92
+ model=model_id,
93
+ tensor_parallel_size=tensor_parallel_size,
94
+ gpu_memory_utilization=gpu_memory_utilization,
95
+ # enforce_eager=True,
96
+ limit_mm_per_prompt=limit_mm_per_prompt,
97
+ )
98
+ processor = AutoProcessor.from_pretrained(model_id)
99
+ sampling_params = SamplingParams(
100
+ max_tokens=max_new_tokens,
101
+ temperature=temperature,
102
+ top_p=top_p,
103
+ repetition_penalty=repetition_penalty,
104
+ )
105
+
106
+
107
+ # -----------------------------
108
+ # pipeline:边准备边推理边写出(动态提交 + 行缓冲)
109
+ # -----------------------------
110
+ output_jsonl.parent.mkdir(parents=True, exist_ok=True)
111
+ prepared_buffer = [] # 缓存已准备好的 (obj, llm_in)
112
+
113
+
114
+ def infer_and_flush(buffer, writer):
115
+ """对 buffer 中的若干样本推理,并写出结果"""
116
+ if not buffer:
117
+ return
118
+ batch_objs = [it[0] for it in buffer]
119
+ batch_inputs = [it[1] for it in buffer]
120
+ gens = llm.generate(batch_inputs, sampling_params)
121
+
122
+ for ob, g in zip(batch_objs, gens):
123
+ text_out = g.outputs[0].text.strip()
124
+
125
+ writer.write({
126
+ "path": ob["path"],
127
+ "labels": ob.get("labels", []), # test 集本身有 labels
128
+ "caption": text_out
129
+ })
130
+
131
+ try:
132
+ # 用行缓冲打开文件,便于“边写边可见”
133
+ f = open(output_jsonl, "w", buffering=1, encoding="utf-8")
134
+ writer = jsonlines.Writer(f)
135
+
136
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
137
+ pbar = tqdm(total=len(metadata), desc="preparing & inferring")
138
+
139
+ # 动态 pending 集合
140
+ pending = set()
141
+ i_submit = 0
142
+
143
+ # 先填满 in-flight
144
+ while i_submit < len(metadata) and len(pending) < inflight_limit:
145
+ fut = ex.submit(prepare_one, metadata[i_submit], processor)
146
+ pending.add(fut)
147
+ i_submit += 1
148
+
149
+ # 循环直到所有任务完成
150
+ while pending:
151
+ # 只等待当前 pending 集合���的任务
152
+ for fut in as_completed(list(pending), timeout=None):
153
+ pending.remove(fut)
154
+ obj, llm_in = fut.result()
155
+ prepared_buffer.append((obj, llm_in))
156
+ pbar.update(1)
157
+
158
+ # 满一批就立刻推理并清空对应部分
159
+ if len(prepared_buffer) >= batch_size:
160
+ infer_and_flush(prepared_buffer[:batch_size], writer)
161
+ prepared_buffer = prepared_buffer[batch_size:]
162
+
163
+ # 补交新任务,保持 in-flight 数量
164
+ while i_submit < len(metadata) and len(pending) < inflight_limit:
165
+ fut_new = ex.submit(prepare_one, metadata[i_submit], processor)
166
+ pending.add(fut_new)
167
+ i_submit += 1
168
+
169
+ # 跳出到 while pending,重新评估 pending 集合(已更新)
170
+ break
171
+
172
+ # 把“尾巴”按 batch 循环清空,确保不丢最后一个或多个 batch
173
+ while prepared_buffer:
174
+ chunk = prepared_buffer[:batch_size]
175
+ infer_and_flush(chunk, writer)
176
+ prepared_buffer = prepared_buffer[len(chunk):]
177
+
178
+ pbar.close()
179
+ finally:
180
+ # 关闭 writer / 文件句柄
181
+ try:
182
+ writer.close()
183
+ except Exception:
184
+ pass
185
+ try:
186
+ f.close()
187
+ except Exception:
188
+ pass
189
+ # 优雅关闭 vLLM 引擎
190
+ try:
191
+ llm.shutdown()
192
+ except Exception:
193
+ pass
194
+
195
+ print(f"done. captions saved to: {output_jsonl}")
UCPE/tools/caption_panshot.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from vllm import LLM, SamplingParams
2
+
3
+ from pathlib import Path
4
+ import jsonlines
5
+ from tqdm.auto import tqdm
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ import os
8
+ import json
9
+ import re
10
+
11
+ from transformers import AutoProcessor
12
+ from qwen_vl_utils import process_vision_info
13
+
14
+
15
+ # -----------------------------
16
+ # basic configuration
17
+ # -----------------------------
18
+ split = "train" # "train" or "test"
19
+ data_root = Path("data/UCPE/PanShot")
20
+ video_dir = data_root / f"videos-{split}"
21
+ output_jsonl = data_root / f"captioned-{split}.jsonl"
22
+
23
+ model_id = "Qwen/Qwen2.5-VL-7B-Instruct" # try 7B first; switch to 32B if resources allow
24
+ # model_id = "chancharikm/qwen2.5-vl-7b-cam-motion-preview"
25
+ nframes = 32 # hint for frame sampling inside qwen_vl_utils
26
+ fps_hint = None # None or a small integer like 1/2/4 (optional)
27
+ batch_size = 8 # how many videos per vLLM.generate batch
28
+ max_workers = min(8, os.cpu_count() or 4) # 线程数按机器调整
29
+ inflight_limit = batch_size * 2 # 同时在制的样本上限
30
+ print(f"Using max_workers={max_workers}, inflight_limit={inflight_limit}")
31
+
32
+
33
+ max_new_tokens = 512
34
+ temperature = 0.2
35
+ top_p = 0.9
36
+ repetition_penalty = 1.05
37
+ gpu_memory_utilization = 0.9
38
+ tensor_parallel_size = 1
39
+ limit_mm_per_prompt = {"video": 1}
40
+
41
+
42
+ def build_llm_input(video_path: Path, prompt_text: str, processor: AutoProcessor):
43
+ # compose messages (system + user text + video item)
44
+ video_item = {"type": "video", "video": str(video_path), "nframes": nframes}
45
+ if fps_hint is not None:
46
+ video_item["fps"] = fps_hint
47
+
48
+ messages = [
49
+ {"role": "system", "content": "You are a helpful video captioning assistant."},
50
+ {"role": "user", "content": [
51
+ {"type": "text", "text": prompt_text},
52
+ video_item
53
+ ]}
54
+ ]
55
+
56
+ # extract frames / prepare tensors for the model (CPU/I/O-heavy)
57
+ image_inputs, video_inputs = process_vision_info(messages)
58
+
59
+ # text template → prompt string
60
+ prompt = processor.apply_chat_template(
61
+ messages, tokenize=False, add_generation_prompt=True
62
+ )
63
+
64
+ mm_data = {}
65
+ if video_inputs is not None:
66
+ mm_data["video"] = video_inputs
67
+
68
+ return {"prompt": prompt, "multi_modal_data": mm_data}
69
+
70
+
71
+ def prepare_one(video, processor: AutoProcessor):
72
+ """单样本:基于 labels/seed_caption 构造提示 → 抽帧预处理 → 组装 vLLM 输入"""
73
+ vpath = video_dir / f"{video}.mp4"
74
+ prompt_text = "Please describe this video in detail."
75
+ try:
76
+ llm_in = build_llm_input(vpath, prompt_text, processor)
77
+ except Exception as e:
78
+ print(f"Error processing {video}: {e}")
79
+ return None
80
+ return video, llm_in
81
+
82
+
83
+ # -----------------------------
84
+ # load metadata
85
+ # -----------------------------
86
+ videos = [v.stem for v in video_dir.glob("*.mp4")]
87
+ print(f"Found {len(videos)} videos in {video_dir}")
88
+ processed = set()
89
+ if output_jsonl.exists():
90
+ print(f"Resuming from {output_jsonl}")
91
+ with open(output_jsonl, "r", encoding="utf-8") as f_in:
92
+ for line in f_in:
93
+ try:
94
+ rec = json.loads(line)
95
+ processed.add(rec["video"])
96
+ except Exception:
97
+ continue
98
+ print(f"Found {len(processed)} processed videos to skip.")
99
+ videos = [v for v in videos if v not in processed]
100
+ print(f"Total videos to process: {len(videos)}")
101
+
102
+ # -----------------------------
103
+ # init vLLM + processor
104
+ # -----------------------------
105
+ llm = LLM(
106
+ model=model_id,
107
+ tensor_parallel_size=tensor_parallel_size,
108
+ gpu_memory_utilization=gpu_memory_utilization,
109
+ # enforce_eager=True,
110
+ limit_mm_per_prompt=limit_mm_per_prompt,
111
+ )
112
+ processor = AutoProcessor.from_pretrained(model_id)
113
+ sampling_params = SamplingParams(
114
+ max_tokens=max_new_tokens,
115
+ temperature=temperature,
116
+ top_p=top_p,
117
+ repetition_penalty=repetition_penalty,
118
+ )
119
+
120
+
121
+ # -----------------------------
122
+ # pipeline:边准备边推理边写出(动态提交 + 行缓冲)
123
+ # -----------------------------
124
+ output_jsonl.parent.mkdir(parents=True, exist_ok=True)
125
+ prepared_buffer = [] # 缓存已准备好的 (obj, llm_in)
126
+
127
+
128
+ def infer_and_flush(buffer, writer):
129
+ """对 buffer 中的若干样本推理,并写出结果"""
130
+ if not buffer:
131
+ return
132
+ batch_videos = [it[0] for it in buffer]
133
+ batch_inputs = [it[1] for it in buffer]
134
+ gens = llm.generate(batch_inputs, sampling_params)
135
+
136
+ for video, g in zip(batch_videos, gens):
137
+ text_out = g.outputs[0].text.strip()
138
+
139
+ writer.write({
140
+ "video": video,
141
+ "caption": text_out
142
+ })
143
+
144
+ try:
145
+ # 用行缓冲打开文件,便于“边写边可见”
146
+ f = open(output_jsonl, "a", buffering=1, encoding="utf-8")
147
+ writer = jsonlines.Writer(f)
148
+
149
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
150
+ pbar = tqdm(total=len(videos), desc="preparing & inferring")
151
+
152
+ # 动态 pending 集合
153
+ pending = set()
154
+ i_submit = 0
155
+
156
+ # 先填满 in-flight
157
+ while i_submit < len(videos) and len(pending) < inflight_limit:
158
+ fut = ex.submit(prepare_one, videos[i_submit], processor)
159
+ pending.add(fut)
160
+ i_submit += 1
161
+
162
+ # 循环直到所有任务完成
163
+ while pending:
164
+ # 只等待当前 pending 集合中的任务
165
+ for fut in as_completed(list(pending), timeout=None):
166
+ pending.remove(fut)
167
+ result = fut.result()
168
+ pbar.update(1)
169
+ if result is None:
170
+ continue
171
+ prepared_buffer.append(result)
172
+
173
+ # 满一批就立刻推理并清空对应部分
174
+ if len(prepared_buffer) >= batch_size:
175
+ infer_and_flush(prepared_buffer[:batch_size], writer)
176
+ prepared_buffer = prepared_buffer[batch_size:]
177
+
178
+ # 补交新任务,保持 in-flight 数量
179
+ while i_submit < len(videos) and len(pending) < inflight_limit:
180
+ fut_new = ex.submit(prepare_one, videos[i_submit], processor)
181
+ pending.add(fut_new)
182
+ i_submit += 1
183
+
184
+ # 跳出到 while pending,重新评估 pending 集合(已更新)
185
+ break
186
+
187
+ # 把“尾巴”按 batch 循环清空,确保不丢最后一个或多个 batch
188
+ while prepared_buffer:
189
+ chunk = prepared_buffer[:batch_size]
190
+ infer_and_flush(chunk, writer)
191
+ prepared_buffer = prepared_buffer[len(chunk):]
192
+
193
+ pbar.close()
194
+ finally:
195
+ # 关闭 writer / 文件句柄
196
+ try:
197
+ writer.close()
198
+ except Exception:
199
+ pass
200
+ try:
201
+ f.close()
202
+ except Exception:
203
+ pass
204
+ # 优雅关闭 vLLM 引擎
205
+ try:
206
+ llm.shutdown()
207
+ except Exception:
208
+ pass
209
+
210
+ print(f"done. captions saved to: {output_jsonl}")
UCPE/tools/dataset_statistics.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import pandas as pd
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import tyro
6
+ from pydantic import BaseModel
7
+ import numpy as np
8
+ from tqdm.auto import tqdm
9
+ from torch.utils.data import DataLoader, Subset
10
+ import matplotlib.pyplot as plt
11
+ import seaborn as sns
12
+
13
+ from src.dataset import PanShotDataset, Re10kDataset, DemoDataset
14
+ from einops import rearrange
15
+
16
+
17
+ # ======================================================
18
+ # seaborn global theme (官方模板)
19
+ # ======================================================
20
+ sns.set_theme(
21
+ context="paper",
22
+ style="whitegrid",
23
+ palette="deep",
24
+ font_scale=0.9,
25
+ )
26
+
27
+
28
+ # ======================================================
29
+ # Args
30
+ # ======================================================
31
+ class Args(BaseModel):
32
+ data: str = "PanShotDataset"
33
+ num_frames: int = 81
34
+ data_root: Path = Path("data/UCPE")
35
+ num_workers: int = 4
36
+ zero_first_yaw: bool = True
37
+ output_dir: Path = Path("outputs/suppl")
38
+ num_samples: Optional[int] = None
39
+ split: str = "train"
40
+ color: str = "C0" # NEW: color
41
+
42
+
43
+ # ======================================================
44
+ # DataLoader
45
+ # ======================================================
46
+ def collate_fn(samples):
47
+ return samples[0]
48
+
49
+
50
+ def prepare_dataloader(args):
51
+ dataset_class = globals().get(args.data, None)
52
+ dataset = dataset_class(
53
+ args, args.split,
54
+ load_keys=["pose", "xi", "y_fov"]
55
+ )
56
+
57
+ if args.num_samples is not None:
58
+ dataset = Subset(dataset, list(range(args.num_samples)))
59
+
60
+ return DataLoader(
61
+ dataset,
62
+ collate_fn=collate_fn,
63
+ batch_size=1,
64
+ num_workers=args.num_workers,
65
+ shuffle=False,
66
+ )
67
+
68
+
69
+ # ======================================================
70
+ # Camera Euler: x-right, y-down, z-forward
71
+ # ======================================================
72
+ def rotmat_to_euler_cam(R):
73
+ fx, fy, fz = R[:, 2] # camera forward in world
74
+ yaw = np.arctan2(fx, fz)
75
+ pitch = np.arctan2(-fy, np.sqrt(fx**2 + fz**2))
76
+ roll = np.arctan2(R[1, 0], R[0, 0])
77
+ return np.degrees([yaw, pitch, roll])
78
+
79
+
80
+ def rot_angle(R0, Ri):
81
+ R = R0.T @ Ri
82
+ cos_theta = (np.trace(R) - 1) / 2
83
+ cos_theta = np.clip(cos_theta, -1, 1)
84
+ return np.degrees(np.arccos(cos_theta))
85
+
86
+
87
+ # ======================================================
88
+ # MAIN
89
+ # ======================================================
90
+ def main():
91
+ args = tyro.cli(Args)
92
+ args.output_dir.mkdir(parents=True, exist_ok=True)
93
+ dataloader = prepare_dataloader(args)
94
+
95
+ # seaborn palette → choose refined colors
96
+ COLOR_MAP = {
97
+ "C0": sns.color_palette("deep")[0],
98
+ "C1": sns.color_palette("deep")[1],
99
+ "C2": sns.color_palette("deep")[2],
100
+ "C3": sns.color_palette("deep")[3],
101
+ }
102
+ COLOR = COLOR_MAP.get(args.color, args.color)
103
+
104
+ # ======================================================
105
+ # containers
106
+ # ======================================================
107
+ final_rel_yaw = [] # displacement-based azimuth
108
+ init_pitch = []
109
+ init_roll = []
110
+ rotation_magnitude = []
111
+ max_rel_yaw = [] # NEW: per-clip max camera relative yaw (signed)
112
+ xis = []
113
+ fovs = []
114
+
115
+ # ======================================================
116
+ # iterate dataset
117
+ # ======================================================
118
+ for data in tqdm(dataloader, desc=f"Processing {args.data}"):
119
+
120
+ poses = data["pose"] # (N, 3, 4)
121
+ xi = float(data["xi"])
122
+ fov = float(data["y_fov"])
123
+
124
+ xis.append(xi)
125
+ fovs.append(fov)
126
+
127
+ R_all = poses[:, :, :3]
128
+ R0 = R_all[0]
129
+
130
+ # Initial orientation
131
+ yaw0, pitch0, roll0 = rotmat_to_euler_cam(R0)
132
+ init_pitch.append(pitch0)
133
+ init_roll.append(roll0)
134
+
135
+ # ------- Displacement-based azimuth (position) -------
136
+ p0 = poses[0, :, 3]
137
+ pN = poses[-1, :, 3]
138
+ v = pN - p0
139
+ yaw_pos = np.arctan2(v[0], v[2])
140
+ final_rel_yaw.append(np.degrees(yaw_pos))
141
+
142
+ # ------- Rotation magnitude wrt frame 0 (unsigned) -------
143
+ rotation_magnitude.append(max(
144
+ rot_angle(R0, Ri) for Ri in R_all
145
+ ))
146
+
147
+ # ------- NEW: max camera relative yaw (signed) -------
148
+ rel_yaws = []
149
+ for Ri in R_all:
150
+ yaw_i, _, _ = rotmat_to_euler_cam(Ri)
151
+ rel_yaws.append(yaw_i)
152
+ rel_yaws = np.array(rel_yaws)
153
+
154
+ # pick the frame with largest |relative yaw|, keep sign
155
+ idx_max = np.argmax(np.abs(rel_yaws))
156
+ max_rel_yaw.append(rel_yaws[idx_max])
157
+
158
+ # convert to numpy
159
+ final_rel_yaw = np.array(final_rel_yaw)
160
+ init_pitch = np.array(init_pitch)
161
+ init_roll = np.array(init_roll)
162
+ rotation_magnitude = np.array(rotation_magnitude)
163
+ max_rel_yaw = np.array(max_rel_yaw)
164
+ xis = np.array(xis)
165
+ fovs = np.array(fovs)
166
+
167
+ # remove top 1% roll outliers
168
+ roll_thr = np.percentile(init_roll, 99)
169
+ init_roll = init_roll[init_roll <= roll_thr]
170
+
171
+ # remove bottom 1% roll outliers
172
+ roll_thr = np.percentile(init_roll, 1)
173
+ init_roll = init_roll[init_roll >= roll_thr]
174
+
175
+ # ======================================================
176
+ # save helper
177
+ # ======================================================
178
+ def save_pdf(fig, name):
179
+ fig.savefig(args.output_dir / f"{name}.pdf",
180
+ dpi=300,
181
+ bbox_inches="tight",
182
+ format="pdf")
183
+ plt.close(fig)
184
+
185
+ # ======================================================
186
+ # 1. Rose plot: displacement azimuth (position)
187
+ # ======================================================
188
+ fig = plt.figure(figsize=(2.0, 2.0))
189
+ ax = plt.subplot(111, polar=True)
190
+
191
+ rad = np.radians(final_rel_yaw)
192
+
193
+ ax.grid(True, linewidth=0.4, alpha=0.5)
194
+ ax.set_facecolor("white")
195
+
196
+ ax.hist(rad, bins=36, alpha=0.55, color=COLOR,
197
+ edgecolor=".3", linewidth=0.4)
198
+
199
+ ax.set_theta_zero_location("N")
200
+ ax.set_theta_direction(-1)
201
+
202
+ ax.set_thetagrids(
203
+ angles=np.arange(0, 360, 45),
204
+ labels=[f"{(a if a <= 180 else a - 360)}°" for a in np.arange(0, 360, 45)]
205
+ )
206
+
207
+ # ax.set_title("Azimuth", pad=8)
208
+ save_pdf(fig, "rose_direction")
209
+
210
+ # ======================================================
211
+ # NEW: 1b. Rose plot: max camera relative yaw (signed)
212
+ # ======================================================
213
+ fig = plt.figure(figsize=(2.0, 2.0))
214
+ ax = plt.subplot(111, polar=True)
215
+
216
+ rad_max_yaw = np.radians(max_rel_yaw)
217
+
218
+ ax.grid(True, linewidth=0.4, alpha=0.5)
219
+ ax.set_facecolor("white")
220
+
221
+ ax.hist(rad_max_yaw, bins=36, alpha=0.55, color=COLOR,
222
+ edgecolor=".3", linewidth=0.4)
223
+
224
+ ax.set_theta_zero_location("N")
225
+ ax.set_theta_direction(-1)
226
+
227
+ ax.set_thetagrids(
228
+ angles=np.arange(0, 360, 45),
229
+ labels=[f"{(a if a <= 180 else a - 360)}°" for a in np.arange(0, 360, 45)]
230
+ )
231
+
232
+ # ax.set_title("Max Relative Yaw", pad=8)
233
+ save_pdf(fig, "rose_rotation") # 文件名仍叫 rose_rotation
234
+
235
+ # ======================================================
236
+ # 2. Pitch histogram (seaborn)
237
+ # ======================================================
238
+ fig = plt.figure(figsize=(2.2, 2.0))
239
+ sns.histplot(init_pitch, bins=40, kde=False, stat="density",
240
+ color=COLOR, edgecolor=".3", linewidth=0.4, alpha=0.6)
241
+ plt.xlabel("Pitch (°)")
242
+ plt.ylabel("Density")
243
+ # plt.title("Initial Pitch")
244
+ save_pdf(fig, "pitch_hist")
245
+
246
+
247
+ # ======================================================
248
+ # 3. Roll histogram
249
+ # ======================================================
250
+ fig = plt.figure(figsize=(2.2, 2.0))
251
+ sns.histplot(init_roll, bins=40, kde=False, stat="density",
252
+ color=COLOR, edgecolor=".3", linewidth=0.4, alpha=0.6)
253
+ plt.xlabel("Roll (°)")
254
+ plt.ylabel("Density")
255
+ # plt.title("Initial Roll")
256
+ save_pdf(fig, "roll_hist")
257
+
258
+
259
+ # ======================================================
260
+ # 4. Rotation magnitude
261
+ # ======================================================
262
+ fig = plt.figure(figsize=(2.2, 2.0))
263
+ sns.histplot(rotation_magnitude, bins=40, kde=False, stat="density",
264
+ color=COLOR, edgecolor=".3", linewidth=0.4, alpha=0.6)
265
+ plt.xlabel("Maximum Rotation (°)")
266
+ plt.ylabel("Density")
267
+ # plt.title("Rotation Magnitude")
268
+ save_pdf(fig, "rotation_magnitude_hist")
269
+
270
+
271
+ # ======================================================
272
+ # 5. ξ histogram
273
+ # ======================================================
274
+ fig = plt.figure(figsize=(2.2, 2.0))
275
+ sns.histplot(xis, bins=30, kde=False, stat="density",
276
+ color=COLOR, edgecolor=".3", linewidth=0.4, alpha=0.6)
277
+ plt.xlabel("ξ")
278
+ plt.ylabel("Density")
279
+ # plt.title("ξ Distribution")
280
+ save_pdf(fig, "xi_hist")
281
+
282
+
283
+ # ======================================================
284
+ # 6. FoV histogram
285
+ # ======================================================
286
+ # remove top 1% FoV outliers
287
+ fov_thr = np.percentile(fovs, 99)
288
+ fovs = fovs[fovs <= fov_thr]
289
+ # remove bottom 1% FoV outliers
290
+ fov_thr = np.percentile(fovs, 1)
291
+ fovs = fovs[fovs >= fov_thr]
292
+
293
+ fig = plt.figure(figsize=(2.2, 2.0))
294
+ sns.histplot(fovs, bins=30, kde=False, stat="density",
295
+ color=COLOR, edgecolor=".3", linewidth=0.4, alpha=0.6)
296
+ plt.xlabel("FoV (°)")
297
+ plt.ylabel("Density")
298
+ # plt.title("FoV Distribution")
299
+ save_pdf(fig, "fov_hist")
300
+
301
+
302
+ # ======================================================
303
+ if __name__ == "__main__":
304
+ main()
UCPE/tools/download_panflow.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from tqdm.auto import tqdm
3
+ import json
4
+ import yt_dlp
5
+
6
+
7
+ panflow_root = Path("data/360-1M")
8
+ panshot_root = Path("data/UCPE")
9
+ pf_clip_root = panshot_root / "PanFlow" / "match_clips"
10
+ pf_video_root = panshot_root / "PanFlow" / "videos"
11
+ pf_video_root.mkdir(parents=True, exist_ok=True)
12
+
13
+ clip_metas = list(pf_clip_root.glob("*.json"))
14
+ clip_metas.sort()
15
+ print(f"Found {len(clip_metas)} PanFlow clip files.")
16
+
17
+ videos = set()
18
+ for clip_meta in clip_metas:
19
+ with open(clip_meta, "r") as f:
20
+ meta = json.load(f)
21
+ videos.add(meta["video_id"])
22
+ print(f"Found {len(videos)} unique PanFlow videos.")
23
+
24
+ downloaded_videos = pf_video_root.glob("*.mp4")
25
+ downloaded_videos = set([p.stem for p in downloaded_videos])
26
+ print(f"Found {len(downloaded_videos)} already downloaded videos.")
27
+
28
+ videos = videos - downloaded_videos
29
+ print(f"{len(videos)} videos to download.")
30
+
31
+
32
+ def download_video(video_id, output_path):
33
+ video_url = f"https://www.youtube.com/watch?v={video_id}"
34
+ ydl_opts = {
35
+ "outtmpl": str(output_path),
36
+ "format": "bestvideo[height<=2500][height>1500]",
37
+ "quiet": False,
38
+ "no_warnings": True,
39
+ "simulate": False,
40
+ "cookiefile": "~/.config/cookies.txt",
41
+ "print": [
42
+ "before_dl:Format: %(format_id)s | Res: %(resolution)s | FPS: %(fps)s",
43
+ "after_move:Size: %(filesize:.2fMB)s"
44
+ ],
45
+ "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
46
+ }
47
+
48
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
49
+ ydl.download([video_url])
50
+
51
+ for video_id in tqdm(videos, desc="Downloading videos"):
52
+ video_path = Path(pf_video_root) / f"{video_id}.mp4"
53
+ try:
54
+ download_video(video_id, video_path)
55
+ except Exception as e:
56
+ tqdm.write(f"Failed to download {video_id}: {e}")
57
+ continue
UCPE/tools/export_camerabench.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import jsonlines
3
+ from pathlib import Path
4
+ from tqdm.auto import tqdm
5
+ import jsonlines
6
+
7
+ # -----------------------------
8
+ # paths
9
+ # -----------------------------
10
+ data_root = Path("data/UCPE/CameraBench")
11
+ filtered_jsonl = data_root / "filtered.jsonl"
12
+ split = "train" # "train" or "test"
13
+ input_jsonl = data_root / f"captioned_{split}.jsonl"
14
+ camera_jsonl = data_root / f"processed_{split}.jsonl"
15
+ output_csv = data_root / f"metadata_{split}.csv"
16
+ output_jsonl = data_root / f"metadata_{split}.jsonl"
17
+
18
+ with jsonlines.open(filtered_jsonl, "r") as reader:
19
+ filtered_videos = {obj["path"] for obj in reader if any(obj["filter"].values())}
20
+
21
+ # 保证输出目录存在
22
+ output_csv.parent.mkdir(parents=True, exist_ok=True)
23
+
24
+ total = 0
25
+ written = 0
26
+ skipped = 0
27
+ filtered = 0
28
+
29
+ with jsonlines.open(input_jsonl, "r") as reader, \
30
+ open(output_csv, "w", newline="", encoding="utf-8") as fout, \
31
+ jsonlines.open(output_jsonl, "w") as jsonl_writer, \
32
+ jsonlines.open(camera_jsonl, "r") as camera_reader:
33
+ camera_metas = {obj["path"]: obj for obj in camera_reader}
34
+ writer = csv.writer(fout)
35
+ # 表头
36
+ writer.writerow(["video", "prompt"])
37
+
38
+ for obj in tqdm(reader, desc="Converting JSONL → CSV"):
39
+ total += 1
40
+ path = obj.get("path", None)
41
+ caption = obj.get("caption", None)
42
+
43
+ if path is None or caption is None:
44
+ skipped += 1
45
+ continue
46
+
47
+ if path in filtered_videos:
48
+ filtered += 1
49
+ continue
50
+
51
+ # 规范化 caption 的空白字符,避免 CSV 里出现杂乱换行
52
+ caption_norm = " ".join(str(caption).split())
53
+ jsonl_writer.write({
54
+ "video": Path(path).stem,
55
+ "prompt": caption_norm,
56
+ "camera_caption": camera_metas[path]["caption"],
57
+ "camera_labels": camera_metas[path].get("labels", []),
58
+ })
59
+ writer.writerow([path, caption_norm])
60
+ written += 1
61
+
62
+ print(f"Done. Total lines: {total}, written: {written}, skipped (missing fields): {skipped}, filtered: {filtered}")
63
+ print(f"CSV saved to: {output_csv}")
64
+ print(f"JSONL saved to: {output_jsonl}")
UCPE/tools/export_camerabench_for_rerender.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Export CameraBench videos needed for PanShot re-rendering.
3
+
4
+ This script replaces the train-split part of process_camerabench.py
5
+ by reading the list of needed cb_videos directly from PanShot meta files,
6
+ bypassing the need for cam_motion/captionset.json.
7
+
8
+ Usage:
9
+ conda activate UCPE
10
+ python tools/export_camerabench_for_rerender.py
11
+ """
12
+
13
+ from pathlib import Path
14
+ import json
15
+ import jsonlines
16
+ import ffmpeg
17
+ import cv2
18
+ from tqdm.auto import tqdm
19
+
20
+
21
+ # ===== Configuration =====
22
+ panshot_root = Path("/tmp/data/UCPE/PanShot")
23
+ camerabench_data_root = Path("data/CameraBench")
24
+ output_root = Path("data/UCPE/CameraBench")
25
+
26
+ target_height = 720
27
+ target_width = 1280
28
+ target_frames = 81
29
+ target_fps = 16
30
+
31
+ output_root.mkdir(parents=True, exist_ok=True)
32
+
33
+
34
+ # ===== Collect needed cb_videos from PanShot meta files =====
35
+ cb_videos_needed = set()
36
+ for split in ["train", "test"]:
37
+ meta_root = panshot_root / f"meta-{split}"
38
+ for f in meta_root.glob("*.json"):
39
+ with open(f) as fp:
40
+ for m in json.load(fp):
41
+ cb_videos_needed.add(m["cb_video"])
42
+ print(f"Total unique cb_videos needed: {len(cb_videos_needed)}")
43
+
44
+
45
+ # ===== Find video files =====
46
+ # Videos come from two sources:
47
+ # - data/CameraBench/videos/ (from videos.zip, train split)
48
+ # - data/CameraBench/data/videos/ (from Videos4CameraBnech, test split)
49
+ video_search_dirs = [
50
+ camerabench_data_root / "videos",
51
+ camerabench_data_root / "data" / "videos",
52
+ ]
53
+
54
+ cb_video_paths = {}
55
+ for cb_video in cb_videos_needed:
56
+ for search_dir in video_search_dirs:
57
+ video_path = search_dir / f"{cb_video}.mp4"
58
+ if video_path.exists():
59
+ cb_video_paths[cb_video] = video_path
60
+ break
61
+
62
+ found = len(cb_video_paths)
63
+ missing = len(cb_videos_needed) - found
64
+ print(f"Found {found} video files, missing {missing}")
65
+ if missing > 0:
66
+ not_found = cb_videos_needed - set(cb_video_paths.keys())
67
+ print(f"Missing videos: {list(not_found)[:10]}...")
68
+
69
+
70
+ # ===== Export videos at target resolution =====
71
+ exported = 0
72
+ skipped = 0
73
+ filtered = 0
74
+ processed_meta = []
75
+
76
+ for cb_video, video_path in tqdm(sorted(cb_video_paths.items()), desc="Exporting videos"):
77
+ output_video = output_root / "videos" / f"{cb_video}.mp4"
78
+
79
+ # Check video properties
80
+ cap = cv2.VideoCapture(str(video_path))
81
+ if not cap.isOpened():
82
+ tqdm.write(f" Failed to open {video_path}, skipping")
83
+ skipped += 1
84
+ continue
85
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
86
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
87
+ fps = cap.get(cv2.CAP_PROP_FPS)
88
+ num_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
89
+ cap.release()
90
+
91
+ num_frames_sampled = int(num_frames / fps * target_fps) if fps > 0 else 0
92
+
93
+ # Filter
94
+ if num_frames_sampled < target_frames:
95
+ tqdm.write(f" {cb_video}: filtered by frames {num_frames_sampled} < {target_frames}")
96
+ filtered += 1
97
+ continue
98
+ if h < target_height or w < target_width:
99
+ tqdm.write(f" {cb_video}: filtered by resolution {w}x{h}")
100
+ filtered += 1
101
+ continue
102
+
103
+ # Export
104
+ if not output_video.exists():
105
+ output_video.parent.mkdir(parents=True, exist_ok=True)
106
+ in_stream = ffmpeg.input(str(video_path))
107
+ v = (
108
+ in_stream.video
109
+ .filter('fps', fps=target_fps)
110
+ .filter('scale',
111
+ f'if(gt(a,{target_width}/{target_height}),-2,{target_width})',
112
+ f'if(gt(a,{target_width}/{target_height}),{target_height},-2)')
113
+ .filter('crop',
114
+ target_width, target_height,
115
+ f'(in_w-{target_width})/2', f'(in_h-{target_height})/2')
116
+ )
117
+ out = ffmpeg.output(
118
+ v,
119
+ str(output_video),
120
+ vcodec='libx264',
121
+ pix_fmt='yuv420p',
122
+ r=target_fps,
123
+ vframes=target_frames,
124
+ )
125
+ ffmpeg.run(out, overwrite_output=True, quiet=True)
126
+
127
+ processed_meta.append({
128
+ "path": f"videos/{cb_video}.mp4",
129
+ "video": cb_video,
130
+ })
131
+ exported += 1
132
+
133
+ print(f"Exported: {exported}, skipped: {skipped}, filtered: {filtered}")
134
+
135
+ # Save processed metadata for both splits (combined since vipe processes all at once)
136
+ for split in ["train", "test"]:
137
+ jsonl_file = output_root / f"processed_{split}.jsonl"
138
+ with jsonlines.open(jsonl_file, "w") as writer:
139
+ writer.write_all(processed_meta)
140
+ print(f"Saved {jsonl_file}")
UCPE/tools/export_figure.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import pandas as pd
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import tyro
6
+ from pydantic import BaseModel
7
+ import numpy as np
8
+ from PIL import Image
9
+ import shutil
10
+ from tqdm.auto import tqdm
11
+ from torch.utils.data import DataLoader
12
+ from src.dataset import PanShotDataset, Re10kDataset, DemoDataset
13
+ from einops import rearrange
14
+ import src.camera_control as ucpe
15
+ import torch
16
+ import imageio
17
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
18
+ import matplotlib.pyplot as plt
19
+
20
+
21
+ class Args(BaseModel):
22
+ methods: list[str]
23
+ data: str = "DemoDataset"
24
+ num_frames: int = 81
25
+ data_root: Path = Path("data/UCPE")
26
+ panshot_data_root: Path = Path("data/UCPE")
27
+ re10k_data_root: Path = Path("data/RealEstate10k")
28
+ input_file: Path = Path("demo/teaser.json")
29
+ num_workers: int = 2
30
+ zero_first_yaw: bool = True
31
+ output_dir: Path = Path("outputs/figures")
32
+ sample_frames: Optional[int] = 4
33
+ padding: int = 25
34
+ quality: int = 95
35
+ video_ids: Optional[list[str]] = None
36
+ fps: int = 16
37
+ animate_latup: bool = False
38
+
39
+
40
+ def collate_fn(samples):
41
+ data = samples[0]
42
+ return data
43
+
44
+
45
+ def prepare_dataloader(args, result_root=None, video_ids=None):
46
+ dataset_class = globals().get(args.data, None)
47
+ dataset = dataset_class(args, "test", load_keys=["pose", "result"], result_root=result_root, video_ids=video_ids)
48
+ dataloader = DataLoader(
49
+ dataset,
50
+ collate_fn=collate_fn,
51
+ batch_size=1,
52
+ num_workers=args.num_workers,
53
+ shuffle=False,
54
+ )
55
+ return dataloader
56
+
57
+
58
+ def main():
59
+ args = tyro.cli(Args)
60
+
61
+ dataloaders = {}
62
+ for method, result in zip(args.methods[::2], args.methods[1::2]):
63
+ dataloader = prepare_dataloader(args, result, args.video_ids)
64
+ dataloaders[method] = dataloader
65
+
66
+ if args.video_ids is None:
67
+ args.video_ids = None
68
+ for dataloader in dataloaders.values():
69
+ video_ids = set()
70
+ for meta in dataloader.dataset.metas:
71
+ video_ids.add(meta["video_id"])
72
+ args.video_ids = video_ids if args.video_ids is None else video_ids & args.video_ids
73
+ args.video_ids = list(args.video_ids)
74
+ dataloaders = {}
75
+ for method, result in zip(args.methods[::2], args.methods[1::2]):
76
+ dataloader = prepare_dataloader(args, result, args.video_ids)
77
+ dataloaders[method] = dataloader
78
+
79
+ print(f"Found {len(args.video_ids)} videos to process.")
80
+
81
+ for datas in tqdm(zip(*dataloaders.values()), desc=f"Exporting figures", total=len(dataloader)):
82
+ data = datas[0]
83
+ video_id = data["video_id"]
84
+ result_id = Path(data["result_path"]).stem
85
+
86
+ # Save prompts
87
+ prompt_dir = args.output_dir / "prompts"
88
+ prompt_path = prompt_dir / f"{video_id}.txt"
89
+ if not prompt_path.exists():
90
+ prompt_dir.mkdir(parents=True, exist_ok=True)
91
+ with open(prompt_path, "w") as f:
92
+ f.write(data["caption"])
93
+
94
+ # Save Lat-up map visualization
95
+ lat_up_dir = args.output_dir / "lat_up_map"
96
+ lat_up_path = lat_up_dir / f"{video_id}.png"
97
+ if not lat_up_path.exists():
98
+ rot = torch.from_numpy(data["pose"][..., :3, :3]).float() # [T, 3, 3]
99
+ rot = rot[:1].unsqueeze(0) # [B=1, 1, 3, 3]
100
+ up_map, lat_map = ucpe.compute_up_lat_map(
101
+ R=rot, # [B, T, 3, 3]
102
+ x_fov=torch.tensor(data["x_fov"]).float().unsqueeze(0), # [B=1, 1]
103
+ xi=torch.tensor(data["xi"]).float().unsqueeze(0), # [B=1, 1]
104
+ height=30,
105
+ width=52,
106
+ )
107
+
108
+ lat_up_dir.mkdir(parents=True, exist_ok=True)
109
+ ucpe.visualize_up_lat_map(
110
+ up_map[0, 0],
111
+ lat_map[0, 0],
112
+ str(lat_up_path),
113
+ )
114
+ tqdm.write(f"Saved lat-up map to {lat_up_path}")
115
+
116
+ lat_up_dir = args.output_dir / "lat_up_video"
117
+ lat_up_path = lat_up_dir / f"{video_id}.mp4"
118
+ if args.animate_latup and not lat_up_path.exists():
119
+ rot = torch.from_numpy(data["pose"][..., :3, :3]).float() # [T, 3, 3]
120
+ rot = rot.unsqueeze(0) # [B=1, T, 3, 3]
121
+ up_map, lat_map = ucpe.compute_up_lat_map(
122
+ R=rot, # [B, T, 3, 3]
123
+ x_fov=torch.tensor(data["x_fov"]).float().unsqueeze(0), # [B=1, 1]
124
+ xi=torch.tensor(data["xi"]).float().unsqueeze(0), # [B=1, 1]
125
+ height=30,
126
+ width=52,
127
+ )
128
+
129
+ lat_up_dir.mkdir(parents=True, exist_ok=True)
130
+ writer = imageio.get_writer(lat_up_path, fps=args.fps)
131
+ for up, lat in zip(up_map[0], lat_map[0]):
132
+ fig = ucpe.visualize_up_lat_map(up, lat)
133
+ canvas = FigureCanvasAgg(fig)
134
+ canvas.draw()
135
+ buf = canvas.buffer_rgba()
136
+ img = np.asarray(buf, dtype=np.uint8)
137
+ writer.append_data(img[:, :, :3])
138
+ plt.close(fig)
139
+ writer.close()
140
+ tqdm.write(f"Saved lat-up video to {lat_up_path}")
141
+
142
+ grid_image_path = args.output_dir / "grid" / f"{result_id}.jpg"
143
+ if not grid_image_path.exists():
144
+ frame_methods = []
145
+ H, W = datas[-1]["result"].shape[2:4]
146
+ for method, data in zip(dataloaders.keys(), datas):
147
+ frames = data["result"]
148
+ frames = (frames + 1.0) / 2.0 * 255.0
149
+ frames = frames.astype(np.uint8)
150
+ frames = rearrange(frames, "C T H W -> T H W C") # (T, H, W, 3)
151
+ total_frames = len(frames)
152
+ if args.sample_frames < total_frames:
153
+ frame_indices = np.linspace(0, total_frames - 1, args.sample_frames, dtype=int)
154
+ else:
155
+ frame_indices = np.arange(total_frames)
156
+ frames = frames[frame_indices] # (sample_frames, H, W, 3)
157
+
158
+ # Save frames as images
159
+ output_frames_dir = args.output_dir / method / result_id
160
+ output_frames_dir.mkdir(parents=True, exist_ok=True)
161
+ scaled = []
162
+ for i, frame in enumerate(frames):
163
+ frame_path = output_frames_dir / f"{i}.jpg"
164
+ frame = Image.fromarray(frame)
165
+ frame.save(frame_path, quality=args.quality)
166
+ frame = frame.resize((W, H), Image.LANCZOS)
167
+ scaled.append(frame)
168
+
169
+ frame_methods.append(scaled)
170
+
171
+ # Save frames as a grid image
172
+ grid_image = Image.new(
173
+ 'RGB',
174
+ (
175
+ W * len(frame_indices) + args.padding * (len(frame_indices) - 1),
176
+ H * len(frame_methods) + args.padding * (len(frame_methods) - 1),
177
+ ),
178
+ (255, 255, 255)
179
+ )
180
+ for j, frames in enumerate(frame_methods):
181
+ for i, frame in enumerate(frames):
182
+ grid_image.paste(frame, (
183
+ i * (W + args.padding),
184
+ j * (H + args.padding),
185
+ ))
186
+ grid_image_path.parent.mkdir(parents=True, exist_ok=True)
187
+ grid_image.save(grid_image_path, quality=args.quality)
188
+ tqdm.write(f"Saved grid image to {grid_image_path}")
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
UCPE/tools/export_table.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import pandas as pd
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import tyro
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class Args(BaseModel):
10
+ methods: list[str]
11
+ metrics: Optional[list[str]] = None
12
+ pad_cols: int = 0
13
+
14
+
15
+ metric_infos = {
16
+ "video_metrics/rho_pred": {
17
+ "name": "${\\rho}_{\\text{A}}$",
18
+ "group": "Camera Lens Control",
19
+ "higher_is_better": True,
20
+ "scale": 100,
21
+ "decimal_places": 2,
22
+ },
23
+ "video_metrics/rho_gt": {
24
+ "name": "${\\rho}_{\\text{A-gt}}$",
25
+ "group": "Camera Lens Control",
26
+ "higher_is_better": True,
27
+ "scale": 100,
28
+ "decimal_places": 2,
29
+ },
30
+ "video_metrics/vfov_err": {
31
+ "name": "FoV (°)",
32
+ "group": "Camera Lens Control",
33
+ "higher_is_better": False,
34
+ "decimal_places": 2,
35
+ },
36
+ "video_metrics/k1_err": {
37
+ "name": "${k}_{1}$",
38
+ "group": "Camera Lens Control",
39
+ "higher_is_better": False,
40
+ "decimal_places": 3,
41
+ },
42
+ "video_metrics/k2_err": {
43
+ "name": "${k}_{2}$",
44
+ "group": "Camera Lens Control",
45
+ "higher_is_better": False,
46
+ "decimal_places": 3,
47
+ },
48
+ "video_metrics/pitch_err": {
49
+ "name": "Pitch (°)",
50
+ "group": "Absolute Orientation",
51
+ "higher_is_better": False,
52
+ "decimal_places": 2,
53
+ },
54
+ "video_metrics/roll_err": {
55
+ "name": "Roll (°)",
56
+ "group": "Absolute Orientation",
57
+ "higher_is_better": False,
58
+ "decimal_places": 2,
59
+ },
60
+ "video_metrics/gravity_err": {
61
+ "name": "Gravity (°)",
62
+ "group": "Absolute Orientation",
63
+ "higher_is_better": False,
64
+ "decimal_places": 2,
65
+ },
66
+ "video_metrics/latitude_err": {
67
+ "name": "Latitude (°)",
68
+ "group": "Absolute Orientation",
69
+ "higher_is_better": False,
70
+ "decimal_places": 2,
71
+ },
72
+ "video_metrics/up_err": {
73
+ "name": "Up (°)",
74
+ "group": "Absolute Orientation",
75
+ "higher_is_better": False,
76
+ "decimal_places": 2,
77
+ },
78
+ "video_metrics/lpips": {
79
+ "name": "LPIPS",
80
+ "group": "Relative Camera Pose Control",
81
+ "higher_is_better": False,
82
+ "decimal_places": 3,
83
+ },
84
+ "video_metrics/psnr": {
85
+ "name": "PSNR",
86
+ "group": "Relative Camera Pose Control",
87
+ "higher_is_better": True,
88
+ "decimal_places": 2,
89
+ },
90
+ "video_metrics/ssim": {
91
+ "name": "SSIM",
92
+ "group": "Relative Camera Pose Control",
93
+ "higher_is_better": True,
94
+ "decimal_places": 3,
95
+ },
96
+ "pose/rot_err": {
97
+ "name": "RotErr (°)",
98
+ "group": "Relative Camera Pose Control",
99
+ "higher_is_better": False,
100
+ "decimal_places": 2,
101
+ },
102
+ "pose/trans_err": {
103
+ "name": "TransErr",
104
+ "group": "Relative Camera Pose Control",
105
+ "higher_is_better": False,
106
+ "decimal_places": 2,
107
+ },
108
+ "pose/cammc": {
109
+ "name": "CamMC",
110
+ "group": "Relative Camera Pose Control",
111
+ "higher_is_better": False,
112
+ "decimal_places": 2,
113
+ },
114
+ "pose/rot_err_vipe": {
115
+ "name": "RotErr - Vipe (°)",
116
+ "group": "Relative Camera Pose Control",
117
+ "higher_is_better": False,
118
+ "decimal_places": 2,
119
+ },
120
+ "pose/trans_err_vipe": {
121
+ "name": "TransErr - Vipe",
122
+ "group": "Relative Camera Pose Control",
123
+ "higher_is_better": False,
124
+ "decimal_places": 2,
125
+ },
126
+ "pose/cammc_vipe": {
127
+ "name": "CamMC - Vipe",
128
+ "group": "Relative Camera Pose Control",
129
+ "higher_is_better": False,
130
+ "decimal_places": 2,
131
+ },
132
+ "video_metrics/fvd_center": {
133
+ "name": "FVD-center",
134
+ "group": "Video Generation Quality",
135
+ "higher_is_better": False,
136
+ "decimal_places": 2,
137
+ },
138
+ "video_metrics/fvd": {
139
+ "name": "FVD",
140
+ "group": "Video Generation Quality",
141
+ "higher_is_better": False,
142
+ "decimal_places": 2,
143
+ },
144
+ "video_metrics/fid": {
145
+ "name": "FID",
146
+ "group": "Video Generation Quality",
147
+ "higher_is_better": False,
148
+ "decimal_places": 2,
149
+ },
150
+ "video_metrics/cs_text": {
151
+ "name": "CLIP",
152
+ "group": "Video Generation Quality",
153
+ "higher_is_better": True,
154
+ "decimal_places": 2,
155
+ },
156
+ "video_metrics/cs_image": {
157
+ "name": "CLIP-image",
158
+ "group": "Video Generation Quality",
159
+ "higher_is_better": True,
160
+ "decimal_places": 2,
161
+ },
162
+ "video_metrics/is": {
163
+ "name": "IS",
164
+ "group": "Video Generation Quality",
165
+ "higher_is_better": True,
166
+ "decimal_places": 2,
167
+ "std_dev": "video_metrics/is_std"
168
+ },
169
+ "qalign/image_quality": {
170
+ "name": "Image Quality",
171
+ "group": "Video Generation Quality",
172
+ "higher_is_better": True,
173
+ "decimal_places": 4,
174
+ },
175
+ "qalign/image_aesthetic": {
176
+ "name": "Image Aesthetic",
177
+ "group": "Video Generation Quality",
178
+ "higher_is_better": True,
179
+ "decimal_places": 4,
180
+ },
181
+ "qalign/video_quality": {
182
+ "name": "Video Quality",
183
+ "group": "Video Generation Quality",
184
+ "higher_is_better": True,
185
+ "decimal_places": 4,
186
+ },
187
+ }
188
+
189
+ color_cells = [
190
+ "firstcell",
191
+ "secondcell",
192
+ "thirdcell",
193
+ ]
194
+
195
+
196
+ def main():
197
+ args = tyro.cli(Args)
198
+
199
+ rows = []
200
+ metrics = metric_infos.keys() if args.metrics is None else [
201
+ metric for metric in metric_infos.keys() if metric in args.metrics
202
+ ]
203
+ for method, result in zip(args.methods[::2], args.methods[1::2]):
204
+ if result == Path(""):
205
+ rows.append({
206
+ "Method": method,
207
+ **{metric_infos[metric]["name"]: None for metric in metrics}
208
+ })
209
+ continue
210
+
211
+ with open(result, "r") as f:
212
+ data = json.load(f)
213
+
214
+ row = {"Method": method}
215
+ for metric in metrics:
216
+ metric_name = metric_infos[metric]["name"]
217
+ row[metric_name] = data[metric] * metric_infos[metric].get("scale", 1.)
218
+ rows.append(row)
219
+ df = pd.DataFrame(rows)
220
+ df = df.round({metric_infos[metric]["name"]: metric_infos[metric]["decimal_places"] for metric in metrics})
221
+ print(df)
222
+
223
+ for metric_info in metric_infos.values():
224
+ col = metric_info["name"]
225
+ if col not in df.columns:
226
+ continue
227
+
228
+ ranks = df[col].dropna().rank(
229
+ method="dense",
230
+ ascending=not metric_info["higher_is_better"],
231
+ ).astype(int) - 1
232
+
233
+ df[col] = df.apply(
234
+ lambda row: (f"\\{color_cells[ranks[row.name]]} " if pd.notna(row[col]) and ranks[row.name] < len(color_cells) else "") +
235
+ (f"{row[col]:.{metric_info['decimal_places']}f}" if pd.notna(row[col]) else ""),
236
+ axis=1
237
+ )
238
+
239
+ for i in range(args.pad_cols):
240
+ df.insert(loc=i, column=i, value="")
241
+
242
+ tuples = [("", "")] * args.pad_cols + [("", "Method")]
243
+ for metric in metrics:
244
+ info = metric_infos[metric]
245
+ group = info["group"]
246
+ name_with_arrow = info["name"] + ("$\\uparrow$" if info["higher_is_better"] else "$\\downarrow$")
247
+ tuples.append((group, name_with_arrow))
248
+ df.columns = pd.MultiIndex.from_tuples(tuples, names=["Group", "Metric"])
249
+
250
+ latex_table = df.to_latex(
251
+ index=False,
252
+ multicolumn=True,
253
+ multicolumn_format="c",
254
+ multirow=True,
255
+ column_format="r" * args.pad_cols + "l" + "c" * (len(df.columns) - 1 - args.pad_cols),
256
+ )
257
+ print(latex_table)
258
+
259
+
260
+ if __name__ == "__main__":
261
+ main()
UCPE/tools/extract_camerabench_poses.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Extract CameraBench gravity-aligned poses from vipe output + geocalib.
3
+ This is the first part of align_panflow.py, extracted to avoid needing PanFlow data.
4
+ """
5
+
6
+ from pathlib import Path
7
+ import numpy as np
8
+ import jsonlines
9
+ from tqdm.auto import tqdm
10
+ from einops import repeat
11
+
12
+ camerabench_root = Path("data/UCPE/CameraBench")
13
+
14
+ # Load geocalib data
15
+ cb_geocalib_file = camerabench_root / "geocalib.jsonl"
16
+ with jsonlines.open(cb_geocalib_file, "r") as reader:
17
+ cb_geocalib = {obj["video"]: obj for obj in reader}
18
+
19
+ # Load CameraBench processed metadata (both splits have same content)
20
+ cb_meta_file = camerabench_root / "processed_train.jsonl"
21
+ with jsonlines.open(cb_meta_file, "r") as reader:
22
+ cb_meta_all = list(reader)
23
+
24
+ cb_R_gravity = []
25
+ cb_poses = []
26
+ cb_meta = []
27
+ static_words = ["static", "still", "stationary", "fixed", "no motion", "no camera motion"]
28
+
29
+ for obj in tqdm(cb_meta_all, desc="Loading CameraBench poses"):
30
+ obj["video"] = Path(obj["path"]).stem
31
+ video_id = obj["video"]
32
+
33
+ pose_file = camerabench_root / "vipe" / "pose" / f"{video_id}.npz"
34
+ if not pose_file.exists():
35
+ tqdm.write(f"Pose file not found: {pose_file}, skipping.")
36
+ continue
37
+
38
+ if video_id not in cb_geocalib:
39
+ tqdm.write(f"Geocalib not found for {video_id}, skipping.")
40
+ continue
41
+
42
+ cb_meta.append(obj)
43
+ pose = np.load(pose_file)["data"] # (T, 4, 4)
44
+ cb_poses.append(pose)
45
+ cb_R_gravity.append(cb_geocalib[video_id]["R"])
46
+
47
+ print(f"Loaded {len(cb_poses)} / {len(cb_meta_all)} CameraBench poses.")
48
+ cb_poses = np.array(cb_poses) # (N, T, 4, 4)
49
+ cb_R_gravity = np.array(cb_R_gravity) # (N, 3, 3)
50
+
51
+ # Normalize: first frame at origin
52
+ cb_w2c0 = np.linalg.inv(cb_poses[:, 0]) # (N, 4, 4)
53
+ cb_poses_origin = cb_w2c0[:, None] @ cb_poses # (N, T, 4, 4)
54
+
55
+ # Apply gravity alignment
56
+ cb_T_gravity = repeat(np.eye(4), 'h w -> n h w', n=cb_R_gravity.shape[0])
57
+ cb_T_gravity[:, :3, :3] = cb_R_gravity
58
+ cb_poses_gravity = cb_T_gravity[:, None, :, :] @ cb_poses_origin
59
+
60
+ # Save poses
61
+ cb_pose_root = camerabench_root / "pose"
62
+ cb_pose_root.mkdir(parents=True, exist_ok=True)
63
+ for i, obj in enumerate(cb_meta):
64
+ out_file = cb_pose_root / f"{obj['video']}.npy"
65
+ np.save(out_file, cb_poses_gravity[i])
66
+
67
+ print(f"Saved {len(cb_meta)} pose files to {cb_pose_root}")
UCPE/tools/filter_camerabench.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from vllm import LLM, SamplingParams
2
+
3
+ from pathlib import Path
4
+ import jsonlines
5
+ from tqdm.auto import tqdm
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ import os
8
+ import json
9
+ import re
10
+
11
+ from transformers import AutoProcessor
12
+ from qwen_vl_utils import process_vision_info
13
+
14
+
15
+ # -----------------------------
16
+ # basic configuration
17
+ # -----------------------------
18
+ data_root = Path("data/UCPE/CameraBench")
19
+ videos = sorted((data_root / "videos").glob("*.mp4")) # 直接遍历视频文件
20
+ output_jsonl = data_root / f"filtered.jsonl"
21
+
22
+ model_id = "Qwen/Qwen2.5-VL-7B-Instruct" # try 7B first; switch to 32B if resources allow
23
+ # model_id = "chancharikm/qwen2.5-vl-7b-cam-motion-preview"
24
+ nframes = 32 # hint for frame sampling inside qwen_vl_utils
25
+ fps_hint = None # None or a small integer like 1/2/4 (optional)
26
+ batch_size = 8 # how many videos per vLLM.generate batch
27
+ max_workers = min(8, os.cpu_count() or 4) # 线程数按机器调整
28
+ inflight_limit = batch_size * 2 # 同时在制的样本上限
29
+ print(f"Using max_workers={max_workers}, inflight_limit={inflight_limit}")
30
+
31
+ max_new_tokens = 512
32
+ temperature = 0.2
33
+ top_p = 0.9
34
+ repetition_penalty = 1.05
35
+ gpu_memory_utilization = 0.9
36
+ tensor_parallel_size = 1
37
+ limit_mm_per_prompt = {"video": 1}
38
+
39
+ prompt_text = """
40
+ You are a video filtering assistant.
41
+ Your task is to analyze the given panoramic video and decide whether it meets certain quality and format requirements.
42
+ Check the following conditions and output results strictly in JSON format, with boolean values (true/false) for each label:
43
+
44
+ - non_fullscreen_or_black_borders: true if the video is not full-screen, has black borders, or is vertical instead of wide.
45
+ - has_subtitles_or_watermarks: true if subtitles, captions, or watermarks are visible.
46
+ - is_cartoon_or_flat_style: true if the video is animated, cartoon-like, 2D flat style, or non-photorealistic.
47
+
48
+ Important:
49
+ – Output strictly in JSON format.
50
+ – Do not add extra text or explanation.
51
+ – Each key must exist, with true/false values.
52
+
53
+ Example output:
54
+ {
55
+ "non_fullscreen_or_black_borders": false,
56
+ "has_subtitles_or_watermarks": true,
57
+ "is_cartoon_or_flat_style": false
58
+ }
59
+ """
60
+
61
+
62
+ # ===================================================================
63
+ # 从这里开始:重写为“VLM 布尔标签 + JSONL 写出”的完整流程
64
+ # ===================================================================
65
+
66
+ def build_llm_input(video_path: Path, processor: AutoProcessor):
67
+ """构造单条输入,包含视频与文本 prompt。"""
68
+ video_item = {"type": "video", "video": str(video_path), "nframes": nframes}
69
+ if fps_hint is not None:
70
+ video_item["fps"] = fps_hint
71
+
72
+ messages = [
73
+ {"role": "system", "content": "You are a helpful video filtering assistant."},
74
+ {
75
+ "role": "user",
76
+ "content": [
77
+ {"type": "text", "text": prompt_text},
78
+ video_item
79
+ ]
80
+ }
81
+ ]
82
+
83
+ # 预处理多模态输入(抽帧/张量装配)
84
+ _, video_inputs = process_vision_info(messages)
85
+
86
+ # 模板化为可生成的字符串
87
+ prompt = processor.apply_chat_template(
88
+ messages, tokenize=False, add_generation_prompt=True
89
+ )
90
+
91
+ mm_data = {}
92
+ if video_inputs is not None:
93
+ mm_data["video"] = video_inputs
94
+
95
+ return {"prompt": prompt, "multi_modal_data": mm_data}
96
+
97
+
98
+ def prepare_one(vpath: Path, processor: AutoProcessor):
99
+ """单样本:基于固定过滤 prompt → 组装 vLLM 输入。"""
100
+ llm_in = build_llm_input(vpath, processor)
101
+ obj = {"path": vpath.relative_to(data_root)}
102
+ return obj, llm_in
103
+
104
+
105
+ # -----------------------------
106
+ # init vLLM + processor
107
+ # -----------------------------
108
+ llm = LLM(
109
+ model=model_id,
110
+ tensor_parallel_size=tensor_parallel_size,
111
+ gpu_memory_utilization=gpu_memory_utilization,
112
+ # enforce_eager=True,
113
+ limit_mm_per_prompt=limit_mm_per_prompt,
114
+ )
115
+ processor = AutoProcessor.from_pretrained(model_id)
116
+ sampling_params = SamplingParams(
117
+ max_tokens=max_new_tokens,
118
+ temperature=temperature,
119
+ top_p=top_p,
120
+ repetition_penalty=repetition_penalty,
121
+ )
122
+
123
+
124
+ # -----------------------------
125
+ # utils
126
+ # -----------------------------
127
+ def clean_json_output(text: str) -> str:
128
+ """清理 Qwen 输出中的 ```json / ``` 包裹,并尝试只保留第一段 JSON。"""
129
+ s = text.strip()
130
+
131
+ # 去除 Markdown 代码围栏
132
+ s = re.sub(r"```(?:json)?", "", s, flags=re.IGNORECASE).strip()
133
+ s = s.replace("```", "").strip()
134
+
135
+ # 尝试截取最外层花括号的 JSON 片段
136
+ # 找到第一个 '{' 与最后一个 '}' 之间的内容
137
+ first = s.find("{")
138
+ last = s.rfind("}")
139
+ if first != -1 and last != -1 and last > first:
140
+ s = s[first:last + 1].strip()
141
+ return s
142
+
143
+
144
+ # -----------------------------
145
+ # 推理与写出
146
+ # -----------------------------
147
+ output_jsonl.parent.mkdir(parents=True, exist_ok=True)
148
+ prepared_buffer = [] # 缓存已准备好的 (obj, llm_in)
149
+
150
+
151
+ def infer_and_flush(buffer, writer):
152
+ """对 buffer 中的若干样本推理,并写出结果(VLM 布尔标签 JSON)。"""
153
+ if not buffer:
154
+ return
155
+ batch_objs = [it[0] for it in buffer]
156
+ batch_inputs = [it[1] for it in buffer]
157
+
158
+ gens = llm.generate(batch_inputs, sampling_params)
159
+
160
+ for ob, g in zip(batch_objs, gens):
161
+ # vLLM 生成的第一个候选
162
+ text_out = (g.outputs[0].text if g.outputs and g.outputs[0].text is not None else "").strip()
163
+
164
+ try:
165
+ cleaned = clean_json_output(text_out)
166
+ parsed = json.loads(cleaned)
167
+
168
+ # 写出:path + 模型布尔标签
169
+ writer.write({
170
+ "path": str(ob["path"]),
171
+ "filter": parsed
172
+ })
173
+
174
+ except Exception as e:
175
+ # 输出无法解析成 JSON:打印日志并跳过
176
+ print(f"[skip] JSON parse failed for {ob['path']}: {e}")
177
+ print(f"Raw output: {text_out}")
178
+ continue
179
+
180
+
181
+ # -----------------------------
182
+ # pipeline:边准备边推理边写出(动态提交 + 行缓冲)
183
+ # -----------------------------
184
+ # 基础检查
185
+ for v in videos:
186
+ assert v.exists(), f"File not found: {v}"
187
+
188
+ try:
189
+ # 用行缓冲打开文件,便于“边写边可见”
190
+ f = open(output_jsonl, "w", buffering=1, encoding="utf-8")
191
+ writer = jsonlines.Writer(f)
192
+
193
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
194
+ pbar = tqdm(total=len(videos), desc="preparing & inferring")
195
+
196
+ # 动态 pending 集合
197
+ pending = set()
198
+ i_submit = 0
199
+
200
+ # 先填满 in-flight
201
+ while i_submit < len(videos) and len(pending) < inflight_limit:
202
+ fut = ex.submit(prepare_one, videos[i_submit], processor)
203
+ pending.add(fut)
204
+ i_submit += 1
205
+
206
+ # 循环直到所有任务完成
207
+ while pending:
208
+ # 只等待当前 pending 集合中的任务
209
+ for fut in as_completed(list(pending), timeout=None):
210
+ pending.remove(fut)
211
+ obj, llm_in = fut.result()
212
+ prepared_buffer.append((obj, llm_in))
213
+ pbar.update(1)
214
+
215
+ # 满一批就立刻推理并清空对应部分
216
+ if len(prepared_buffer) >= batch_size:
217
+ infer_and_flush(prepared_buffer[:batch_size], writer)
218
+ prepared_buffer = prepared_buffer[batch_size:]
219
+
220
+ # 补交新任务,保持 in-flight 数量
221
+ while i_submit < len(videos) and len(pending) < inflight_limit:
222
+ fut_new = ex.submit(prepare_one, videos[i_submit], processor)
223
+ pending.add(fut_new)
224
+ i_submit += 1
225
+
226
+ # 跳出到 while pending,重新评估 pending 集合(已更新)
227
+ break
228
+
229
+ # 把“尾巴”按 batch 循环清空,确保不丢最后一个或多个 batch
230
+ while prepared_buffer:
231
+ chunk = prepared_buffer[:batch_size]
232
+ infer_and_flush(chunk, writer)
233
+ prepared_buffer = prepared_buffer[len(chunk):]
234
+
235
+ pbar.close()
236
+ finally:
237
+ # 关闭 writer / 文件句柄
238
+ try:
239
+ writer.close()
240
+ except Exception:
241
+ pass
242
+ try:
243
+ f.close()
244
+ except Exception:
245
+ pass
246
+ # 优雅关闭 vLLM 引擎
247
+ try:
248
+ llm.shutdown()
249
+ except Exception:
250
+ pass
251
+
252
+ print(f"done. VLM labels saved to: {output_jsonl}")
UCPE/tools/filter_panflow.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from vllm import LLM, SamplingParams
2
+
3
+ from pathlib import Path
4
+ from tqdm.auto import tqdm
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ import os
7
+ import json
8
+ import re
9
+ from decord import VideoReader, cpu
10
+ import numpy as np
11
+ import torch
12
+ from PIL import Image
13
+ ngpus = torch.cuda.device_count()
14
+
15
+ from transformers import AutoProcessor
16
+ from qwen_vl_utils import process_vision_info
17
+
18
+
19
+ # -----------------------------
20
+ # basic configuration
21
+ # -----------------------------
22
+ panflow_root = Path("data/360-1M")
23
+ panshot_root = Path("data/UCPE")
24
+ output_root = panshot_root / "PanFlow" / "filtered"
25
+ output_root.mkdir(parents=True, exist_ok=True)
26
+
27
+ model_id = "Qwen/Qwen2.5-VL-7B-Instruct" # try 7B first; switch to 32B if resources allow
28
+ batch_size = 16 * ngpus # how many videos per vLLM.generate batch
29
+ max_workers = min(4 * ngpus, os.cpu_count() or 4)
30
+ inflight_limit = max_workers * 2
31
+ print(f"Using max_workers={max_workers}, inflight_limit={inflight_limit}")
32
+
33
+ max_new_tokens = 512
34
+ temperature = 0.2
35
+ top_p = 0.9
36
+ repetition_penalty = 1.05
37
+ gpu_memory_utilization = 0.9
38
+ tensor_parallel_size = ngpus
39
+ limit_mm_per_prompt = {"image": 1}
40
+
41
+ meta_root = panflow_root / "meta"
42
+ meta_files = list((meta_root).glob("*.json"))
43
+ meta_files.sort()
44
+ print(f"Found {len(meta_files)} PanFlow meta files.")
45
+
46
+ existing_out = list(output_root.glob("*.json"))
47
+ existing_ids = {f.stem for f in existing_out}
48
+ meta_files = [f for f in meta_files if f.stem not in existing_ids]
49
+ print(f"{len(meta_files)} files to process after skipping existing.")
50
+
51
+ prompt_text = """
52
+ You are a video understanding assistant specialized in analyzing panoramic ERP-format videos.
53
+ Given one frame of a panoramic video, your tasks are:
54
+
55
+ 1. **Filtering**: Identify if the video should be filtered out.
56
+ Output boolean flags for the following conditions (true if the issue exists, false otherwise):
57
+
58
+ - non_ERP_format: The video is **not in ERP (Equirectangular Projection)** panoramic format. For example, if the video looks like a flat perspective, fisheye, cube-map, or any projection other than ERP, set this to true.
59
+
60
+ - has_subtitle_or_watermark: The video contains **text overlays, subtitles, logos, or watermarks**. Look carefully for visible text at the bottom, center, or corners of the video. If such elements are present and not part of the real scene, set this to true.
61
+
62
+ - edge_missing: The top or bottom edges of the ERP panorama are **cut off, blacked out, cropped, or covered by logos/watermarks**, so the full 360° vertical coverage is missing or obstructed. If you cannot clearly see the poles (sky/ground) or if the edges are hidden by overlays, set this to true.
63
+
64
+ - has_overlay: The frame contains **artificial overlays**, such as embedded UI elements, pop-up graphics, stickers, video-in-video inserts, menus, or other synthetic elements that are not part of the natural scene. If you see signs of AR/VR interface, streaming UI, or added images, set this to true.
65
+
66
+ - low_quality: The video is of **poor visual quality**, such as being blurry, noisy, heavily pixelated, very low resolution, or distorted in a way that prevents recognizing the scene. If the content is hard to interpret due to quality issues, set this to true.
67
+
68
+ - unnatural_content: The video contains **cartoons, animations, CGI, synthetic 3D renderings, or game engine graphics** rather than real-world panoramic footage. If the content is not realistic, set this to true.
69
+
70
+ 2. **POI Categorization**: From the provided list of categories, select **one or more most relevant** labels that best describe the scene.
71
+ Only use the given categories, do not invent new ones.
72
+
73
+ ---
74
+
75
+ **poi_category list (choose only from below):**
76
+
77
+ Restaurant, Coffee-Shop, Bars-and-Pubs, Residential-area, Hotels-Motels, Vaccation-Rentals, Hospitals-Clinics, Pharmacies, Dentists, School-Universities, Library, Supermarkets, Shopping-Malls, Clothing-Stores, Shoe-Stores, Bookstores, Flowerstore, Furniture-Stores, Electorical-Store, Pet-Store, Toy-Shop, Airports, Train-Stations, Bus-Stops, Gas-Station, Car-Rental-Agencies, Theaters, Concert-Halls, Sports-Stadiums, Parks-and-Recreation-Areas, Museums, Art-Galleries, Zoos-Aquariums, Botanical-Gardens, Landmarks, Cultural-Centers, Post-Offices, Police-Stations, Courthouses, CityHalls, Banks-ATMs, Events-Conferences-halls, Beaches, Hiking-Trails, Campgrounds, Lakes, Mountains, Forest-Mountains, Farms, Street-View, Square, Business-Centers, Tech-Companies, Co-working-Spaces, Gyms-and-Fitness-Centers, Sports-Clubs, Swimming-Pools, Tennis-Courts, Auto-Repair-Shops, Car-Washes, Parking-Lots, Churches, Mosques, Temples, Graveyards.
78
+
79
+ ---
80
+
81
+ **Output strictly in JSON format** as follows:
82
+
83
+ ```json
84
+ {
85
+ "filter": {
86
+ "non_ERP_format": false,
87
+ "has_subtitle_or_watermark": false,
88
+ "edge_missing": false,
89
+ "has_overlay": false,
90
+ "low_quality": false,
91
+ "unnatural_content": false
92
+ },
93
+ "poi_category": ["Mountains"]
94
+ }
95
+ ```
96
+ """
97
+
98
+ # ================================================================
99
+ # utils
100
+ # ================================================================
101
+
102
+ def clean_json_output(text: str) -> str:
103
+ s = text.strip()
104
+ s = re.sub(r"```(?:json)?", "", s, flags=re.IGNORECASE).strip()
105
+ s = s.replace("```", "").strip()
106
+ first = s.find("{")
107
+ last = s.rfind("}")
108
+ if first != -1 and last != -1 and last > first:
109
+ s = s[first:last + 1].strip()
110
+ return s
111
+
112
+
113
+ def process_one_video(meta_file, processor):
114
+ """子进程:为一个视频的 clips 抽中间帧,生成 LLM 输入"""
115
+ pf_meta = json.load(open(meta_file))
116
+ video_id = pf_meta.get("video_id", Path(meta_file).stem)
117
+ video_path = panflow_root / "videos" / f"{video_id}.mp4"
118
+
119
+ if "slam_clips" not in pf_meta or "clips" not in pf_meta["slam_clips"]:
120
+ print(f"[skip] No slam_clips in meta: {meta_file}")
121
+ return None
122
+
123
+ if not video_path.exists():
124
+ print(f"[skip] Video file not found: {video_path}")
125
+ return None
126
+
127
+ try:
128
+ vr = VideoReader(str(video_path), ctx=cpu(0), num_threads=1)
129
+ except Exception as e:
130
+ print(f"[skip] Failed to open video {video_path}: {e}")
131
+ return None
132
+
133
+ clip_infos, llm_inputs = [], []
134
+
135
+ for clip in pf_meta["slam_clips"]["clips"]:
136
+ start, end = clip["frames"][0], clip["frames"][-1]
137
+ mid = (start + end) // 2
138
+
139
+ frame = vr[mid].asnumpy().astype(np.uint8)
140
+ frame_pil = Image.fromarray(frame)
141
+
142
+ image_item = {"type": "image", "image": frame_pil}
143
+ messages = [
144
+ {"role": "system", "content": "You are a helpful video filtering assistant."},
145
+ {"role": "user", "content": [{"type": "text", "text": prompt_text}, image_item]},
146
+ ]
147
+
148
+ image_inputs, _ = process_vision_info(messages)
149
+ prompt = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
150
+
151
+ mm_data = {}
152
+ if image_inputs is not None:
153
+ mm_data["image"] = image_inputs
154
+
155
+ llm_inputs.append({"prompt": prompt, "multi_modal_data": mm_data})
156
+ clip_infos.append(clip)
157
+
158
+ return video_id, clip_infos, llm_inputs
159
+
160
+
161
+ # ================================================================
162
+ # init vLLM
163
+ # ================================================================
164
+ llm = LLM(
165
+ model=model_id,
166
+ tensor_parallel_size=tensor_parallel_size,
167
+ gpu_memory_utilization=gpu_memory_utilization,
168
+ # enforce_eager=True,
169
+ limit_mm_per_prompt=limit_mm_per_prompt,
170
+ )
171
+ processor = AutoProcessor.from_pretrained(model_id)
172
+ sampling_params = SamplingParams(
173
+ max_tokens=max_new_tokens,
174
+ temperature=temperature,
175
+ top_p=top_p,
176
+ repetition_penalty=repetition_penalty,
177
+ )
178
+
179
+
180
+ # ================================================================
181
+ # flush results
182
+ # ================================================================
183
+ def flush_one_video(video_id, clip_infos, llm_inputs):
184
+ """对单个视频的所有 clips 批量推理并写 JSON。"""
185
+ video_results = []
186
+ total_clips = len(llm_inputs)
187
+
188
+ # 二级进度条:按 clip 数量更新
189
+ with tqdm(total=total_clips, desc=f"Video {video_id}", leave=False) as pbar:
190
+ for i in range(0, total_clips, batch_size):
191
+ chunk = llm_inputs[i:i + batch_size]
192
+ gens = llm.generate(chunk, sampling_params)
193
+ for clip, g in zip(clip_infos[i:i + batch_size], gens):
194
+ text_out = g.outputs[0].text.strip() if g.outputs else ""
195
+ try:
196
+ cleaned = clean_json_output(text_out)
197
+ parsed = json.loads(cleaned)
198
+ video_results.append({
199
+ "clip_id": clip["clip_id"],
200
+ "clip_name": clip["clip_name"],
201
+ "filter": parsed.get("filter", {}),
202
+ "poi_category": parsed.get("poi_category", [])
203
+ })
204
+ except Exception as e:
205
+ print(f"[skip] JSON parse failed for {clip['clip_name']}: {e}")
206
+ print(f"Raw output: {text_out}")
207
+ finally:
208
+ pbar.update(1) # 每处理一个 clip 就更新进度
209
+
210
+ out_file = output_root / f"{video_id}.json"
211
+ with open(out_file, "w", encoding="utf-8") as f:
212
+ json.dump(video_results, f, indent=4)
213
+
214
+
215
+ # ================================================================
216
+ # 主流程
217
+ # ================================================================
218
+
219
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
220
+ pbar = tqdm(total=len(meta_files), desc="processing videos")
221
+ pending = set()
222
+ i_submit = 0
223
+
224
+ # 初始填充 inflight
225
+ while i_submit < len(meta_files) and len(pending) < inflight_limit:
226
+ fut = ex.submit(process_one_video, meta_files[i_submit], processor)
227
+ pending.add(fut)
228
+ i_submit += 1
229
+
230
+ while pending:
231
+ for fut in as_completed(list(pending), timeout=None):
232
+ pending.remove(fut)
233
+ result = fut.result()
234
+ if result is None:
235
+ continue
236
+ video_id, clip_infos, llm_inputs = result
237
+ flush_one_video(video_id, clip_infos, llm_inputs)
238
+
239
+ pbar.update(1)
240
+
241
+ # 补交新任务
242
+ while i_submit < len(meta_files) and len(pending) < inflight_limit:
243
+ fut_new = ex.submit(process_one_video, meta_files[i_submit], processor)
244
+ pending.add(fut_new)
245
+ i_submit += 1
246
+ break
247
+ pbar.close()
248
+
249
+ try:
250
+ llm.shutdown()
251
+ except Exception:
252
+ pass
253
+
254
+ print(f"done. Results saved to {output_root}")
UCPE/tools/geocalib_camerabench.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import jsonlines
2
+ from pathlib import Path
3
+ from tqdm.auto import tqdm
4
+ import jsonlines
5
+ from geocalib import GeoCalib
6
+ from decord import VideoReader, cpu
7
+ import torch
8
+ from einops import rearrange
9
+
10
+
11
+ data_root = Path("data/UCPE/CameraBench")
12
+ videos = sorted((data_root / "videos").glob("*.mp4")) # 直接遍历视频文件
13
+ output_jsonl = data_root / "geocalib.jsonl"
14
+
15
+ processed = set()
16
+ if output_jsonl.exists():
17
+ print(f"Resuming from {output_jsonl}")
18
+ with jsonlines.open(output_jsonl, "r") as reader:
19
+ for obj in reader:
20
+ processed.add(obj["video"])
21
+ print(f"Found {len(processed)} processed videos to skip.")
22
+ videos = [v for v in videos if v.stem not in processed]
23
+ print(f"Total videos to process: {len(videos)}")
24
+
25
+ gc = GeoCalib(weights="pinhole").cuda()
26
+
27
+ with jsonlines.open(output_jsonl, "a") as writer:
28
+ for video in tqdm(videos, desc="Calibrating videos"):
29
+ vr = VideoReader(str(video), ctx=cpu(0), num_threads=1)
30
+ frames = vr.get_batch(range(0, len(vr))).asnumpy()
31
+ frames = torch.from_numpy(frames)
32
+ frames = rearrange(frames, "n h w c -> n c h w")
33
+ frames = frames.float() / 255.0
34
+ frames = frames.cuda()
35
+
36
+ result = gc.calibrate(frames, shared_intrinsics=True)
37
+ gravity = result["gravity"][0]
38
+ R = gravity.R.cpu().numpy().tolist()
39
+ roll, pitch = gravity.rp.cpu().numpy().tolist()
40
+
41
+ writer.write({
42
+ "video": video.stem,
43
+ "R": R,
44
+ "roll": roll,
45
+ "pitch": pitch,
46
+ })
UCPE/tools/match_panflow.py ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import jsonlines
3
+ from tqdm.auto import tqdm
4
+ from collections import defaultdict
5
+ import matplotlib.pyplot as plt
6
+ import ffmpeg
7
+ import json
8
+ import csv
9
+ import numpy as np
10
+ import cv2
11
+ from einops import rearrange, repeat
12
+ from visualize_pose import vis_to_html
13
+ from decord import VideoReader, cpu
14
+ import os
15
+ import shutil
16
+ from scipy.spatial.transform import Rotation as R
17
+ import seaborn as sns
18
+ import pandas as pd
19
+
20
+
21
+ split = "train" # "train" or "test"
22
+ panflow_root = Path("data/360-1M")
23
+ panshot_root = Path("data/UCPE")
24
+ camerabench_root = panshot_root / "CameraBench"
25
+ debug_root = Path("debug/match_panflow")
26
+ match_cb_root = panshot_root / "PanFlow" / f"align_to_camerabench-{split}"
27
+ match_pf_root = panshot_root / "PanFlow" / f"match_to_camerabench-{split}"
28
+ match_pf_root.mkdir(parents=True, exist_ok=True)
29
+ pf_clip_root = panshot_root / "PanFlow" / f"match_clips-{split}"
30
+ pf_clip_root.mkdir(parents=True, exist_ok=True)
31
+ summary_root = match_pf_root.parent / f"{match_pf_root.name}-summary"
32
+ summary_root.mkdir(parents=True, exist_ok=True)
33
+
34
+ max_pf_per_cb = 100 if split == "train" else 1
35
+ max_cb_per_pf = 5 if split == "train" else 3
36
+ matches_per_poi = 1000 if split == "train" else 1
37
+ rotation_score_thres = 5.0
38
+ avg_qalign_thres = 0.7
39
+ watermark_score_thres = 0.3
40
+ motion_score_bins = 10
41
+ max_match_per_bin = 2000 if split == "train" else 10
42
+ visualize_video = False
43
+ visualize_scores = ["motion_score", "watermark_score", "avg_qalign", "rotation_score"]
44
+ skip_undownloaded = True
45
+ filter_cb_per_pf = True
46
+ filter_cb_percentile = 0.8
47
+
48
+ if skip_undownloaded:
49
+ pf_video_root = panshot_root / "PanFlow" / "videos"
50
+ downloaded_videos = set([p.stem for p in pf_video_root.glob("*.mp4")])
51
+ print(f"Found {len(downloaded_videos)} downloaded PanFlow videos.")
52
+
53
+ match_meta_files = list(match_cb_root.glob("*.json"))
54
+ match_meta_files.sort()
55
+ print(f"Found {len(match_meta_files)} match meta files.")
56
+
57
+ if split == "test":
58
+ pf_clip_train = panshot_root / "PanFlow" / "match_clips-train"
59
+ pf_clip_train = pf_clip_train.glob("*.json")
60
+ pf_clip_train = set([p.stem for p in pf_clip_train])
61
+ print(f"Found {len(pf_clip_train)} training PanFlow clips.")
62
+
63
+ print(f"Loading CameraBench poses for filtering...")
64
+ cb_meta_file = camerabench_root / f"processed_{split}.jsonl"
65
+ with jsonlines.open(cb_meta_file, "r") as reader:
66
+ cb_meta_all = list(reader)
67
+
68
+ cb_max_rotations = {}
69
+ for obj in tqdm(cb_meta_all, desc="Loading CameraBench poses"):
70
+ video_id = Path(obj["path"]).stem
71
+ pose_file = camerabench_root / "vipe" / "pose" / f"{video_id}.npz"
72
+ if not pose_file.exists():
73
+ tqdm.write(f"Pose file not found: {pose_file}, skipping.")
74
+ continue
75
+
76
+ pose = np.load(pose_file)["data"] # (T, 4, 4)
77
+ R_all = pose[:, :3, :3] # (T, 3, 3)
78
+ # 计算相邻帧之间的相对旋转
79
+ rel_rot = np.einsum("tij,tjk->tik", np.linalg.inv(R_all[:-1]), R_all[1:])
80
+ # 将相对旋转矩阵转换为旋转角度(度数)
81
+ rel_angles = R.from_matrix(rel_rot).magnitude() * 180.0 / np.pi
82
+ # 取最大旋转角
83
+ cb_max_rotations[video_id] = float(np.max(rel_angles))
84
+
85
+ # sort by max rotation
86
+ cb_max_rotations = sorted(cb_max_rotations.items(), key=lambda x: x[1])
87
+ num_cb_to_keep = int(len(cb_max_rotations) * filter_cb_percentile)
88
+ cb_videos_to_keep = set([v[0] for v in cb_max_rotations[:num_cb_to_keep]])
89
+ print(f"Keeping {len(cb_videos_to_keep)} CameraBench videos for testing.")
90
+
91
+
92
+ def plot_match_histogram(summary, summary_name, summary_file):
93
+ fig, ax = plt.subplots(figsize=(10, 6))
94
+
95
+ items = sorted(summary.items(), key=lambda x: -x[1])
96
+ labels, counts = zip(*items)
97
+ ax.barh(labels, counts, color="salmon")
98
+
99
+ ax.set_xlabel("Match Count")
100
+ ax.set_ylabel("Number of Videos")
101
+ ax.set_title(f"{summary_name} Distribution")
102
+
103
+ plt.tight_layout()
104
+ fig.savefig(summary_file, dpi=300, bbox_inches="tight")
105
+ plt.close(fig)
106
+ print(f"Saved camera summary histogram to {summary_file}")
107
+
108
+
109
+ def plot_match_histogram_category(summary, summary_name, summary_file, max_categories=30):
110
+ """
111
+ seaborn 版本的类别分布绘图:
112
+ - 横轴为类别
113
+ - 纵轴为 density(相对频率)
114
+ - 超过 max_categories 的类别合并为 "others"
115
+ - 输出 PDF(矢量图)
116
+ """
117
+
118
+ # ---------------------------
119
+ # Step 1: 排序并保留 top-K 类别
120
+ # ---------------------------
121
+ items = sorted(summary.items(), key=lambda x: -x[1])
122
+
123
+ if len(items) > max_categories:
124
+ top_items = items[:max_categories]
125
+ others_total = sum([c for _, c in items[max_categories:]])
126
+ top_items.append(("others", others_total))
127
+ else:
128
+ top_items = items
129
+
130
+ labels, counts = zip(*top_items)
131
+
132
+ # ---------------------------
133
+ # Step 2: density = count / total
134
+ # ---------------------------
135
+ total = sum(counts)
136
+ density = [c / total for c in counts]
137
+
138
+ # ---------------------------
139
+ # Step 3: seaborn 绘图
140
+ # ---------------------------
141
+ df = pd.DataFrame({
142
+ "category": labels,
143
+ "density": density,
144
+ "count": counts,
145
+ })
146
+
147
+ fig, ax = plt.subplots(figsize=(12, 6))
148
+ sns.barplot(
149
+ data=df,
150
+ x="category",
151
+ y="density",
152
+ ax=ax,
153
+ palette="viridis"
154
+ )
155
+
156
+ ax.set_xlabel("Category", fontsize=12)
157
+ ax.set_ylabel("Proportion", fontsize=12)
158
+ # ax.set_title(f"{summary_name} (Proportion)", fontsize=14)
159
+ plt.xticks(rotation=45, ha="right")
160
+
161
+ plt.tight_layout()
162
+
163
+ # ---------------------------
164
+ # Step 4: 保存为 PDF(矢量图)
165
+ # ---------------------------
166
+ summary_file = Path(summary_file)
167
+ summary_file = summary_file.with_suffix(".pdf")
168
+ fig.savefig(summary_file, dpi=300, bbox_inches="tight", format="pdf")
169
+
170
+ plt.close(fig)
171
+ print(f"[Saved PDF] {summary_file}")
172
+
173
+
174
+ def plot_score_histogram(motion_scores, summary_file):
175
+ fig, ax = plt.subplots(figsize=(10, 6))
176
+ ax.hist(motion_scores, bins="auto", color="skyblue", edgecolor="black")
177
+ ax.set_xlabel("Score")
178
+ ax.set_ylabel("Number of Clips")
179
+ ax.set_title("PanFlow Clip Score Distribution")
180
+
181
+ plt.tight_layout()
182
+ fig.savefig(summary_file, dpi=300, bbox_inches="tight")
183
+ plt.close(fig)
184
+ print(f"Saved score histogram to {summary_file}")
185
+
186
+
187
+ # match_meta_files = match_meta_files[:100]
188
+ poi_category_count = defaultdict(int)
189
+ metas = []
190
+ for meta_file in tqdm(match_meta_files, desc=f"Loading match metadata"):
191
+ with open(meta_file, "r") as f:
192
+ meta = json.load(f)
193
+ meta["clips"] = [clip for clip in meta["clips"] if "matches" in clip]
194
+ for pf_clip in meta["clips"]:
195
+ for c in pf_clip["poi_category"]:
196
+ poi_category_count[c] += 1
197
+ metas.append(meta)
198
+
199
+ summary_file = summary_root / "poi_category_count.pdf"
200
+ plot_match_histogram_category(poi_category_count, "poi_category_count", summary_file)
201
+ num_clips = sum(poi_category_count.values())
202
+ print(f"Found {len(poi_category_count)} POI categories, covering {num_clips} clips.")
203
+
204
+ match_cands = []
205
+ pf_clips = {}
206
+ for meta in tqdm(metas, desc=f"Preprocessing match metadata"):
207
+ for pf_clip in meta["clips"]:
208
+ pf_key = f"{pf_clip['video_id']}-{pf_clip['clip_id']}"
209
+ if split == "test" and pf_key in pf_clip_train:
210
+ continue
211
+
212
+ pf_clips[pf_key] = {k: pf_clip[k] for k in [
213
+ "scores", "video_id", "clip_id", "clip_name", "poi_category", "frames"
214
+ ]}
215
+ pf_clips[pf_key]["fps"] = meta["fps"]
216
+ pf_clips[pf_key]["time_range"] = (
217
+ pf_clip["frames"][0] / meta["fps"],
218
+ pf_clip["frames"][-1] / meta["fps"],
219
+ )
220
+
221
+ scores = pf_clip["scores"]
222
+ for cb_clip in pf_clip["matches"]:
223
+ if split == "test" and cb_clip["video"] not in cb_videos_to_keep:
224
+ continue
225
+
226
+ if scores["watermark_score"] > watermark_score_thres:
227
+ continue
228
+ if scores["avg_qalign"] < avg_qalign_thres:
229
+ continue
230
+
231
+ rotation_score = (rotation_score_thres - scores["rotation_score"]) / rotation_score_thres
232
+ total_score = \
233
+ 0.3 * (1 - scores["watermark_score"]) \
234
+ + 0.5 * scores["avg_qalign"] \
235
+ + 0.2 * rotation_score
236
+
237
+ match_info = {
238
+ "total_score": total_score,
239
+ "video_id": pf_clip["video_id"],
240
+ "clip_id": pf_clip["clip_id"],
241
+ "frames": cb_clip["frames"],
242
+ "rmse": cb_clip["rmse"],
243
+ "R": cb_clip["R"],
244
+ "s": cb_clip["s"],
245
+ }
246
+ match_info["time_range"] = (
247
+ cb_clip["frames"][0] / meta["fps"],
248
+ cb_clip["frames"][-1] / meta["fps"],
249
+ )
250
+ match_cands.append((cb_clip["video"], match_info))
251
+
252
+ print(f"Found {len(match_cands)} match candidates.")
253
+
254
+ score_summary = defaultdict(list)
255
+ for pf_clip in pf_clips.values():
256
+ for key in visualize_scores:
257
+ score_summary[key].append(pf_clip["scores"][key])
258
+ for key, score in score_summary.items():
259
+ summary_file = summary_root / f"{key}_before.png"
260
+ plot_score_histogram(score, summary_file)
261
+
262
+ motion_scores = score_summary["motion_score"]
263
+ motion_bin_edges = np.quantile(motion_scores, np.linspace(0, 1, motion_score_bins + 1))
264
+ motion_bin_edges = np.unique(motion_bin_edges)
265
+ print(f"Motion score histogram bins: {motion_bin_edges}")
266
+
267
+ cb_matches = defaultdict(list)
268
+ panflow_summary = defaultdict(int)
269
+ poi_category_summary = defaultdict(int)
270
+ motion_bins = defaultdict(int)
271
+ for cb_clip, match_info in tqdm(match_cands, desc=f"Analysing match metadata"):
272
+ pf_key = f"{match_info['video_id']}-{match_info['clip_id']}"
273
+ pf_clip = pf_clips[pf_key]
274
+ motion_score = pf_clip["scores"]["motion_score"]
275
+ bin_idx = np.searchsorted(motion_bin_edges, motion_score, side='right') - 1
276
+ bin_idx = min(bin_idx, len(motion_bin_edges) - 2)
277
+
278
+ if skip_undownloaded and match_info["video_id"] not in downloaded_videos:
279
+ continue
280
+ if motion_bins[bin_idx] >= max_match_per_bin:
281
+ continue
282
+ if pf_key in panflow_summary and panflow_summary[pf_key] >= max_cb_per_pf:
283
+ continue
284
+ if cb_clip in cb_matches and len(cb_matches[cb_clip]) >= max_pf_per_cb:
285
+ continue
286
+ if all(poi_category_summary[c] > matches_per_poi for c in pf_clip["poi_category"]):
287
+ continue
288
+
289
+ for c in pf_clip["poi_category"]:
290
+ poi_category_summary[c] += 1
291
+ cb_matches[cb_clip].append(match_info)
292
+ panflow_summary[pf_key] += 1
293
+ motion_bins[bin_idx] += 1
294
+
295
+ camerabench_summary = {k: len(v) for k, v in cb_matches.items()}
296
+ for summary_name, summary in [
297
+ ("camerabench_summary", camerabench_summary),
298
+ ("panflow_summary", panflow_summary),
299
+ ]:
300
+ summary_file = summary_root / f"{summary_name}.png"
301
+ plot_match_histogram(summary, summary_name, summary_file)
302
+
303
+ plot_match_histogram_category(
304
+ poi_category_summary,
305
+ "poi_category_summary",
306
+ summary_root / "poi_category_summary.pdf"
307
+ )
308
+
309
+ print(f"{len(pf_clips)} PanFlow clips in total.")
310
+ print(f"{len(panflow_summary)} PanFlow clips have >= 1 CameraBench match.")
311
+ print(f"Selected {len(cb_matches)} CameraBench videos.")
312
+ if split == "train" and filter_cb_per_pf:
313
+ pf_keys = {k: v for k, v in panflow_summary.items() if v >= max_cb_per_pf}
314
+ print(f"{len(pf_keys)} PanFlow clips have >= {max_cb_per_pf} CameraBench matches.")
315
+ else:
316
+ pf_keys = panflow_summary
317
+ pf_clips = {k: v for k, v in pf_clips.items() if k in pf_keys}
318
+ panflow_videos = set([c["video_id"] for c in pf_clips.values()])
319
+ print(f"Selected {len(panflow_videos)} PanFlow videos.")
320
+ print(f"Selected {sum([len(v) for v in cb_matches.values()])} total matches.")
321
+
322
+ score_summary = defaultdict(list)
323
+ for pf_clip in pf_clips.values():
324
+ for key in visualize_scores:
325
+ score_summary[key].append(pf_clip["scores"][key])
326
+ for key, score in score_summary.items():
327
+ summary_file = summary_root / f"{key}_after.png"
328
+ plot_score_histogram(score, summary_file)
329
+
330
+ for pf_key, pf_clip in tqdm(pf_clips.items(), desc="Saving selected PanFlow clips"):
331
+ out_file = pf_clip_root / f"{pf_key}.json"
332
+ with open(out_file, "w") as f:
333
+ json.dump(pf_clip, f, indent=4)
334
+
335
+ for cb_video, match_list in tqdm(cb_matches.items(), desc="Saving match results"):
336
+ match_list = [m for m in match_list if f"{m['video_id']}-{m['clip_id']}" in pf_keys]
337
+ out_file = match_pf_root / f"{cb_video}.json"
338
+ with open(out_file, "w") as f:
339
+ json.dump(match_list, f, indent=4)
340
+ # tqdm.write(f"Wrote {len(match_list)} matches to {out_file}")
341
+ # tqdm.write(f"Best score {match_list[0]['total_score']:.4f}, worst score {match_list[-1]['total_score']:.4f}")
342
+
343
+ if visualize_video:
344
+ cb_video_dir = debug_root / cb_video
345
+ cb_video_dir.mkdir(parents=True, exist_ok=True)
346
+ src_video_file = panshot_root / "CameraBench" / "videos" / f"{cb_video}.mp4"
347
+ tgt_video_file = cb_video_dir / "CameraBench.mp4"
348
+ shutil.copy2(src_video_file, tgt_video_file)
349
+
350
+ cap = cv2.VideoCapture(str(src_video_file))
351
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
352
+ fps = cap.get(cv2.CAP_PROP_FPS)
353
+ cap.release()
354
+
355
+ for match in match_list:
356
+ pf_video_file = panflow_root / "videos" / f"{match['video_id']}.mp4"
357
+ vr = VideoReader(str(pf_video_file), ctx=cpu(0), num_threads=1)
358
+ pf_clip = pf_clips[f"{match['video_id']}/{match['clip_id']}"]
359
+ clip_start = pf_clip["frames"][0]
360
+ start_frame, end_frame = match["frames"]
361
+ start_frame = start_frame + clip_start
362
+ end_frame = end_frame + clip_start
363
+ sample_frames = np.linspace(start_frame, end_frame, num=frame_count)
364
+ sample_frames = np.round(sample_frames).astype(int)
365
+ clip = vr.get_batch(sample_frames).asnumpy()
366
+ out_clip_file = cb_video_dir / f"{match['video_id']}-{start_frame}-{end_frame}.mp4"
367
+
368
+ process = (
369
+ ffmpeg.input("pipe:", format="rawvideo", pix_fmt="rgb24", s=f"{clip.shape[2]}x{clip.shape[1]}", r=fps)
370
+ .output(str(out_clip_file), pix_fmt="yuv420p", vcodec="libx264", r=fps, crf=23)
371
+ .overwrite_output()
372
+ .run_async(pipe_stdin=True, quiet=True)
373
+ )
374
+ process.stdin.write(clip.tobytes())
375
+ process.stdin.close()
376
+ process.wait()
UCPE/tools/normalize_panflow.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import jsonlines
3
+ from tqdm.auto import tqdm
4
+ from collections import defaultdict
5
+ import matplotlib.pyplot as plt
6
+ import ffmpeg
7
+ import json
8
+ import csv
9
+ import numpy as np
10
+ import cv2
11
+ from einops import einsum, rearrange, repeat
12
+ from visualize_pose import vis_to_html
13
+ import decord
14
+ from decord import VideoReader, cpu
15
+ decord.bridge.set_bridge("torch")
16
+ import os
17
+ import shutil
18
+ import torch
19
+ from thirdparty.PanFlow.utils.erp_utils import transformation_to_flow
20
+ from thirdparty.PanoFlowAPI.apis.PanoRaft import PanoRAFTAPI
21
+
22
+
23
+ split = "train" # "train" or "test"
24
+ panflow_root = Path("data/360-1M")
25
+ panshot_root = Path("data/UCPE")
26
+ debug_root = Path("debug/match_panflow")
27
+ pf_clip_root = panshot_root / "PanFlow" / f"match_clips-{split}"
28
+ output_jsonl = panshot_root / "PanFlow" / f"near_plane_depth-{split}.jsonl"
29
+ output_jsonl.parent.mkdir(parents=True, exist_ok=True)
30
+ summary_root = output_jsonl.parent / f"{output_jsonl.stem}-summary"
31
+ summary_root.mkdir(parents=True, exist_ok=True)
32
+
33
+ flow_height = 512
34
+ flow_width = 1024
35
+ epipole_thres = 30
36
+ upper_edge_mask = 0.35
37
+ lower_edge_mask = 0.25
38
+ sample_fps = 2
39
+ frame_near_quantile = 25
40
+ video_near_quantile = 50
41
+ batch_size = 24
42
+
43
+ visualize_disp = False
44
+ if visualize_disp:
45
+ disp_root = summary_root / "disp"
46
+ disp_root.mkdir(parents=True, exist_ok=True)
47
+
48
+ device = torch.device("cuda")
49
+ flow_estimater_ckpt = "models/PanoFlow/PanoFlow(RAFT)-wo-CFE.pth"
50
+
51
+ flow_estimater = PanoRAFTAPI(
52
+ device=device, model_path=flow_estimater_ckpt
53
+ )
54
+
55
+ def epipole_flow(pose):
56
+ pose = pose[1:].inverse() @ pose[:-1]
57
+ return transformation_to_flow(pose, (flow_height, flow_width))
58
+
59
+
60
+ def flow_depth(flow, pose, eps=1e-6):
61
+ epi_flow = epipole_flow(pose)
62
+ dot = einsum(flow, epi_flow, "n c h w, n c h w -> n h w")
63
+ epi_norm = epi_flow.norm(dim=1)
64
+ depth = epi_norm ** 2 / dot.clamp_min(eps)
65
+
66
+ flow_norm = flow.norm(dim=1)
67
+ cos = dot / (epi_norm * flow_norm).clamp_min(eps)
68
+ cos = cos.clamp(-1, 1)
69
+ degree = torch.rad2deg(torch.acos(cos))
70
+ invalid = degree > epipole_thres
71
+
72
+ depth[invalid] = float("nan")
73
+ depth[dot < eps] = float("nan")
74
+ height = depth.shape[-2]
75
+ depth[:, :int(upper_edge_mask * height)] = float("nan")
76
+ depth[:, int((1 - lower_edge_mask) * height):] = float("nan")
77
+ return depth
78
+
79
+
80
+ def near_plane_depth(depth):
81
+ depth = rearrange(depth, "n h w -> n (h w)")
82
+ near_depth = torch.nanquantile(depth, frame_near_quantile / 100, dim=-1)
83
+ near_depth = torch.nanquantile(near_depth, video_near_quantile / 100)
84
+ return near_depth.item()
85
+
86
+
87
+ clip_metas = list(pf_clip_root.glob("*.json"))
88
+ clip_metas.sort()
89
+ print(f"Found {len(clip_metas)} PanFlow clip files.")
90
+ # clip_metas = clip_metas[:10] # DEBUG
91
+
92
+ near_depths = []
93
+ try:
94
+ f = open(output_jsonl, "w", buffering=1, encoding="utf-8")
95
+ writer = jsonlines.Writer(f)
96
+
97
+ for clip_meta in tqdm(clip_metas, desc=f"Processing matched clips"):
98
+ with open(clip_meta, "r") as f:
99
+ meta = json.load(f)
100
+
101
+ video_id = meta["video_id"]
102
+ video_file = panflow_root / "videos" / f"{video_id}.mp4"
103
+ vr = VideoReader(str(video_file), width=flow_width, height=flow_height, ctx=cpu(0), num_threads=1)
104
+
105
+ clip_id = meta["clip_id"]
106
+ clip_name = meta["clip_name"]
107
+ frames = meta["frames"]
108
+ num_frames = frames[-1] - frames[0] + 1
109
+ fps = meta["fps"]
110
+ num_frames_sampled = int(round(num_frames / fps * sample_fps))
111
+ if num_frames_sampled < 2:
112
+ tqdm.write(f"Too few frames ({num_frames}) in clip {clip_name}, skipping.")
113
+ continue
114
+ sample_frames = np.linspace(frames[0], frames[-1], num_frames_sampled)
115
+ sample_frames = np.round(sample_frames).astype(int)
116
+
117
+ pose_file = panflow_root / "slam_pose" / video_id / clip_name
118
+ pose_file = pose_file.with_suffix(".npy")
119
+ if not pose_file.exists():
120
+ tqdm.write(f"Pose file not found: {pose_file}, skipping.")
121
+ continue
122
+ c2w = np.load(pose_file) # (T, 3, 4)
123
+ c2w = c2w[sample_frames - frames[0]] # (N, 3, 4)
124
+ c2w_4x4 = np.eye(4, dtype=np.float32)
125
+ c2w = np.hstack((c2w, repeat(c2w_4x4[-1], "n -> f 1 n", f=c2w.shape[0])))
126
+ pf_pose = torch.from_numpy(c2w)
127
+
128
+ video = vr.get_batch(sample_frames)
129
+ flow_in = video.float()
130
+ flow_in = rearrange(flow_in, "n h w c -> n c h w")
131
+
132
+ flow_in = flow_in.to(device)
133
+ flows = flow_estimater.chunk_estimate_flow_cfe(flow_in, chunk_size=batch_size)
134
+ flows = rearrange(flows, "n h w c -> n c h w")
135
+ pf_pose = pf_pose.to(device)
136
+ depth = flow_depth(flows, pf_pose)
137
+ near_depth = near_plane_depth(depth)
138
+
139
+ if visualize_disp:
140
+ # 保存 disparity
141
+ disp_file = disp_root / f"{video_id}-{clip_id}-disp.png"
142
+ rgb_file = disp_root / f"{video_id}-{clip_id}-rgb.png"
143
+
144
+ # ---------- disparity ----------
145
+ depth_vis = depth[0].detach().cpu().numpy() # [H,W]
146
+ disp_vis = np.zeros_like(depth_vis, dtype=np.float32)
147
+ valid = np.isfinite(depth_vis) & (depth_vis > 1e-6)
148
+ disp_vis[valid] = 1.0 / depth_vis[valid]
149
+
150
+ if valid.any():
151
+ dmin = np.nanpercentile(disp_vis[valid], 1)
152
+ dmax = np.nanpercentile(disp_vis[valid], 99)
153
+ dmin = max(dmin, 0)
154
+ norm = np.clip((disp_vis - dmin) / (dmax - dmin + 1e-6), 0, 1)
155
+ disp_color = (plt.cm.magma(norm)[..., :3] * 255).astype(np.uint8)
156
+ else:
157
+ disp_color = np.zeros((*disp_vis.shape, 3), dtype=np.uint8)
158
+
159
+ cv2.imwrite(str(disp_file), cv2.cvtColor(disp_color, cv2.COLOR_RGB2BGR))
160
+
161
+ # ---------- 对应的 RGB ----------
162
+ # video: decord 返回 [N,H,W,C], 取第一帧
163
+ if video.shape[0] > 0:
164
+ rgb = video[0].cpu().numpy() # [H,W,C], float32 0~255?
165
+ if rgb.dtype != np.uint8:
166
+ rgb = np.clip(rgb, 0, 255).astype(np.uint8)
167
+ cv2.imwrite(str(rgb_file), cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR))
168
+
169
+ near_depths.append(near_depth)
170
+ result = {
171
+ "video_id": video_id,
172
+ "clip_name": clip_name,
173
+ "clip_id": clip_id,
174
+ "near_depth": near_depth,
175
+ }
176
+ writer.write(result)
177
+ finally:
178
+ # 关闭 writer / 文件句柄
179
+ try:
180
+ writer.close()
181
+ except Exception:
182
+ pass
183
+ try:
184
+ f.close()
185
+ except Exception:
186
+ pass
187
+
188
+ near_depths = [d for d in near_depths if np.isfinite(d)]
189
+ summary_file = summary_root / "near_plane_depth.png"
190
+
191
+ fig, ax = plt.subplots(figsize=(10, 6))
192
+ ax.hist(near_depths, bins="auto", color="skyblue", edgecolor="black")
193
+ ax.set_xlabel("Near Plane Depth")
194
+ ax.set_ylabel("Number of Clips")
195
+ ax.set_title("Near Plane Depth Distribution")
196
+
197
+ plt.tight_layout()
198
+ fig.savefig(summary_file, dpi=300, bbox_inches="tight")
199
+ plt.close(fig)
200
+ print(f"Saved near plane depth histogram to {summary_file}")
UCPE/tools/pre_normalize_panflow.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import jsonlines
3
+ from tqdm.auto import tqdm
4
+ from collections import defaultdict
5
+ import matplotlib.pyplot as plt
6
+ import ffmpeg
7
+ import json
8
+ import csv
9
+ import numpy as np
10
+ import cv2
11
+ from einops import einsum, rearrange, repeat
12
+ from visualize_pose import vis_to_html
13
+ import decord
14
+ from decord import VideoReader, cpu
15
+ decord.bridge.set_bridge("torch")
16
+ import os
17
+ import shutil
18
+ import torch
19
+ from thirdparty.PanFlow.utils.erp_utils import transformation_to_flow
20
+ from thirdparty.PanoFlowAPI.apis.PanoRaft import PanoRAFTAPI
21
+
22
+
23
+ panflow_root = Path("data/360-1M")
24
+ panshot_root = Path("data/UCPE")
25
+ debug_root = Path("debug/match_panflow")
26
+ match_cb_root = panshot_root / "PanFlow" / "align_to_camerabench"
27
+ output_jsonl = panshot_root / "PanFlow" / "near_plane_depth.jsonl"
28
+ output_jsonl.parent.mkdir(parents=True, exist_ok=True)
29
+ summary_root = output_jsonl.parent / f"{output_jsonl.stem}-summary"
30
+ summary_root.mkdir(parents=True, exist_ok=True)
31
+
32
+ flow_height = 512
33
+ flow_width = 1024
34
+ epipole_thres = 30
35
+ upper_edge_mask = 0.35
36
+ lower_edge_mask = 0.2
37
+ sample_fps = 2
38
+ frame_near_quantile = 5
39
+ video_near_quantile = 10
40
+ batch_size = 24
41
+
42
+ visualize_disp = True
43
+ if visualize_disp:
44
+ disp_root = summary_root / "disp"
45
+ disp_root.mkdir(parents=True, exist_ok=True)
46
+
47
+ device = torch.device("cuda")
48
+ flow_estimater_ckpt = "models/PanoFlow/PanoFlow(RAFT)-wo-CFE.pth"
49
+
50
+ flow_estimater = PanoRAFTAPI(
51
+ device=device, model_path=flow_estimater_ckpt
52
+ )
53
+
54
+ def epipole_flow(pose):
55
+ pose = pose[1:].inverse() @ pose[:-1]
56
+ return transformation_to_flow(pose, (flow_height, flow_width))
57
+
58
+
59
+ def flow_depth(flow, pose, eps=1e-6):
60
+ epi_flow = epipole_flow(pose)
61
+ dot = einsum(flow, epi_flow, "n c h w, n c h w -> n h w")
62
+ epi_norm = epi_flow.norm(dim=1)
63
+ depth = epi_norm ** 2 / dot.clamp_min(eps)
64
+
65
+ flow_norm = flow.norm(dim=1)
66
+ cos = dot / (epi_norm * flow_norm).clamp_min(eps)
67
+ cos = cos.clamp(-1, 1)
68
+ degree = torch.rad2deg(torch.acos(cos))
69
+ invalid = degree > epipole_thres
70
+
71
+ depth[invalid] = float("nan")
72
+ depth[dot < eps] = float("nan")
73
+ height = depth.shape[-2]
74
+ depth[:, :int(upper_edge_mask * height)] = float("nan")
75
+ depth[:, int((1 - lower_edge_mask) * height):] = float("nan")
76
+ return depth
77
+
78
+
79
+ def near_plane_depth(depth):
80
+ depth = rearrange(depth, "n h w -> n (h w)")
81
+ near_depth = torch.nanquantile(depth, frame_near_quantile / 100, dim=-1)
82
+ near_depth = torch.nanquantile(near_depth, video_near_quantile / 100)
83
+ return near_depth.item()
84
+
85
+
86
+ match_meta_files = list(match_cb_root.glob("*.json"))
87
+ match_meta_files.sort()
88
+ match_meta_files = match_meta_files[:10] # DEBUG
89
+ print(f"Found {len(match_meta_files)} match meta files.")
90
+
91
+ near_depths = []
92
+ with jsonlines.open(output_jsonl, "w") as writer:
93
+ for meta_file in tqdm(match_meta_files, desc=f"Processing matched clips"):
94
+ with open(meta_file, "r") as f:
95
+ meta = json.load(f)
96
+
97
+ video_id = meta_file.stem
98
+ video_file = panflow_root / "videos" / f"{video_id}.mp4"
99
+ vr = VideoReader(str(video_file), width=flow_width, height=flow_height, ctx=cpu(0), num_threads=1)
100
+
101
+ for pf_clip in meta["clips"]:
102
+ if "matches" not in pf_clip:
103
+ tqdm.write(f"No matches in clip {pf_clip['clip_name']}, skipping.")
104
+ continue
105
+
106
+ clip_id = pf_clip["clip_id"]
107
+ clip_name = pf_clip["clip_name"]
108
+ frames = pf_clip["frames"]
109
+ num_frames = frames[-1] - frames[0] + 1
110
+ fps = meta["fps"]
111
+ num_frames_sampled = int(round(num_frames / fps * sample_fps))
112
+ if num_frames_sampled < 2:
113
+ tqdm.write(f"Too few frames ({num_frames}) in clip {clip_name}, skipping.")
114
+ continue
115
+ sample_frames = np.linspace(frames[0], frames[-1], num_frames_sampled)
116
+ sample_frames = np.round(sample_frames).astype(int)
117
+
118
+ pose_file = panflow_root / "slam_pose" / video_id / clip_name
119
+ pose_file = pose_file.with_suffix(".npy")
120
+ if not pose_file.exists():
121
+ tqdm.write(f"Pose file not found: {pose_file}, skipping.")
122
+ continue
123
+ c2w = np.load(pose_file) # (T, 3, 4)
124
+ c2w = c2w[sample_frames - frames[0]] # (N, 3, 4)
125
+ c2w_4x4 = np.eye(4, dtype=np.float32)
126
+ c2w = np.hstack((c2w, repeat(c2w_4x4[-1], "n -> f 1 n", f=c2w.shape[0])))
127
+ pf_pose = torch.from_numpy(c2w)
128
+
129
+ video = vr.get_batch(sample_frames)
130
+ flow_in = video.float()
131
+ flow_in = rearrange(flow_in, "n h w c -> n c h w")
132
+
133
+ flow_in = flow_in.to(device)
134
+ flows = flow_estimater.chunk_estimate_flow_cfe(flow_in, chunk_size=batch_size)
135
+ flows = rearrange(flows, "n h w c -> n c h w")
136
+ pf_pose = pf_pose.to(device)
137
+ depth = flow_depth(flows, pf_pose)
138
+ near_depth = near_plane_depth(depth)
139
+
140
+ if visualize_disp:
141
+ # 保存 disparity
142
+ disp_file = disp_root / f"{video_id}-{clip_id}-disp.png"
143
+ rgb_file = disp_root / f"{video_id}-{clip_id}-rgb.png"
144
+
145
+ # ---------- disparity ----------
146
+ depth_vis = depth[0].detach().cpu().numpy() # [H,W]
147
+ disp_vis = np.zeros_like(depth_vis, dtype=np.float32)
148
+ valid = np.isfinite(depth_vis) & (depth_vis > 1e-6)
149
+ disp_vis[valid] = 1.0 / depth_vis[valid]
150
+
151
+ if valid.any():
152
+ dmin = np.nanpercentile(disp_vis[valid], 1)
153
+ dmax = np.nanpercentile(disp_vis[valid], 99)
154
+ dmin = max(dmin, 0)
155
+ norm = np.clip((disp_vis - dmin) / (dmax - dmin + 1e-6), 0, 1)
156
+ disp_color = (plt.cm.magma(norm)[..., :3] * 255).astype(np.uint8)
157
+ else:
158
+ disp_color = np.zeros((*disp_vis.shape, 3), dtype=np.uint8)
159
+
160
+ cv2.imwrite(str(disp_file), cv2.cvtColor(disp_color, cv2.COLOR_RGB2BGR))
161
+
162
+ # ---------- 对应的 RGB ----------
163
+ # video: decord 返回 [N,H,W,C], 取第一帧
164
+ if video.shape[0] > 0:
165
+ rgb = video[0].cpu().numpy() # [H,W,C], float32 0~255?
166
+ if rgb.dtype != np.uint8:
167
+ rgb = np.clip(rgb, 0, 255).astype(np.uint8)
168
+ cv2.imwrite(str(rgb_file), cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR))
169
+
170
+ near_depths.append(near_depth)
171
+ result = {
172
+ "video_id": video_id,
173
+ "clip_name": clip_name,
174
+ "clip_id": clip_id,
175
+ "near_depth": near_depth,
176
+ }
177
+ writer.write(result)
178
+
179
+ near_depths = [d for d in near_depths if np.isfinite(d)]
180
+ summary_file = summary_root / "near_plane_depth.png"
181
+
182
+ fig, ax = plt.subplots(figsize=(10, 6))
183
+ ax.hist(near_depths, bins="auto", color="skyblue", edgecolor="black")
184
+ ax.set_xlabel("Near Plane Depth")
185
+ ax.set_ylabel("Number of Clips")
186
+ ax.set_title("Near Plane Depth Distribution")
187
+
188
+ plt.tight_layout()
189
+ fig.savefig(summary_file, dpi=300, bbox_inches="tight")
190
+ plt.close(fig)
191
+ print(f"Saved near plane depth histogram to {summary_file}")
UCPE/tools/process_camerabench.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import jsonlines
3
+ from tqdm.auto import tqdm
4
+ from collections import defaultdict
5
+ import matplotlib.pyplot as plt
6
+ import ffmpeg
7
+ import json
8
+ import cv2
9
+
10
+
11
+ # 数据与路径设置
12
+ data_root = Path("data/CameraBench")
13
+ output_root = Path("data/UCPE/CameraBench")
14
+ target_height = 720
15
+ target_width = 1280
16
+ target_frames = 81
17
+ target_fps = 16
18
+ banned_labels = ["zoom-in", "zoom-out"]
19
+ banned_keywords = ["zoom"]
20
+ split = "train" # "train" or "test"
21
+ dryrun = False
22
+
23
+ # 读取数据
24
+ if split == "test":
25
+ meta_file = data_root / "test.jsonl"
26
+ with jsonlines.open(meta_file, "r") as reader:
27
+ metadata = list(reader)
28
+ else:
29
+ meta_file = data_root / "cam_motion" / "captionset.json"
30
+ with open(meta_file, "r") as f:
31
+ captionset = json.load(f)
32
+ metadata = []
33
+ videos = set()
34
+ for obj in captionset:
35
+ video = obj["videos"][0]
36
+ if video in videos:
37
+ continue
38
+ videos.add(video)
39
+ metadata.append({
40
+ "path": video,
41
+ "caption": obj["messages"][1]["content"]
42
+ })
43
+
44
+ # 处理与过滤视频
45
+ labels = defaultdict(int)
46
+ frames = []
47
+ heights = []
48
+ fps_list = []
49
+ filtered_meta = []
50
+ for obj in tqdm(metadata, desc="Reading videos"):
51
+ video_file = obj["path"]
52
+ if split == "test":
53
+ for label in obj["labels"]:
54
+ labels[label] += 1
55
+ video_path = data_root / video_file
56
+
57
+ # 获取视频信息
58
+ # meta = ffmpeg.probe(str(video_path)) # 使用 ffprobe 获取全 metadata
59
+ # vstream = next(s for s in meta["streams"] if s["codec_type"] == "video")
60
+ # w = int(vstream["width"])
61
+ # h = int(vstream["height"])
62
+ # fps_str = vstream.get("avg_frame_rate", "0/1")
63
+ # num, den = map(int, fps_str.split('/'))
64
+ # fps = num / den if den != 0 else None
65
+ # num_frames = int(vstream.get("nb_frames"))
66
+
67
+ cap = cv2.VideoCapture(str(video_path))
68
+ if not cap.isOpened():
69
+ tqdm.write(f" - failed to open {video_path}, skipping")
70
+ continue
71
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
72
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
73
+ fps = cap.get(cv2.CAP_PROP_FPS)
74
+ num_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
75
+ cap.release()
76
+
77
+ # 打印视频信息
78
+ num_frames_sampled = int(num_frames / fps * target_fps)
79
+ frames.append(num_frames)
80
+ heights.append(h)
81
+ fps_list.append(fps)
82
+ tqdm.write(f"{video_path} — {num_frames} frames, fps ~ {fps:.2f}, sampled to {num_frames_sampled}, height {h}")
83
+
84
+ # 过滤条件
85
+ if num_frames_sampled < target_frames:
86
+ tqdm.write(f" - filtered by frames: {num_frames_sampled} < {target_frames}")
87
+ continue
88
+ if h < target_height:
89
+ tqdm.write(f" - filtered by height: {h} < {target_height}")
90
+ continue
91
+ if w < target_width:
92
+ tqdm.write(f" - filtered by width: {w} < {target_width}")
93
+ continue
94
+ if split == "test":
95
+ if any(label in banned_labels for label in obj["labels"]):
96
+ tqdm.write(f" - filtered by label: {obj['labels']}")
97
+ continue
98
+ else:
99
+ caption = obj["caption"]
100
+ if any(kw in caption.lower() for kw in banned_keywords):
101
+ tqdm.write(f" - filtered by keyword in caption: {caption}")
102
+ continue
103
+ filtered_meta.append(obj)
104
+
105
+ # 导出视频
106
+ output_video = (output_root / video_file).with_suffix(".mp4")
107
+ tqdm.write(f" - exporting to {output_video}")
108
+ if dryrun:
109
+ continue
110
+ output_video.parent.mkdir(parents=True, exist_ok=True)
111
+
112
+ # 说明:
113
+ # 1) fps 过滤器把时间轴重采样为 target_fps(不会改变播放速度)
114
+ # 2) scale 先把画面按长宽比“铺满”16:9 画幅(a=iw/ih):
115
+ # - 如果原视频更宽(gt(a,16/9)):固定高=720,宽按比例(-2 表示自动,且保证可被2整除)
116
+ # - 否则固定宽=1280,高按比例
117
+ # 3) 再做中心裁剪到 1280x720
118
+ # 4) vframes 只写前 target_frames 帧
119
+ in_stream = ffmpeg.input(str(video_path))
120
+ v = (
121
+ in_stream.video
122
+ .filter('fps', fps=target_fps) # 重新采样到目标帧率
123
+ .filter('scale',
124
+ f'if(gt(a,{target_width}/{target_height}),-2,{target_width})',
125
+ f'if(gt(a,{target_width}/{target_height}),{target_height},-2)')
126
+ .filter('crop',
127
+ target_width, target_height,
128
+ f'(in_w-{target_width})/2', f'(in_h-{target_height})/2')
129
+ )
130
+
131
+ # 如无需音频可去掉 audio;若要保留音频,建议也做 asetpts=PTS-STARTPTS
132
+ out = ffmpeg.output(
133
+ v,
134
+ str(output_video),
135
+ vcodec='libx264',
136
+ pix_fmt='yuv420p',
137
+ r=target_fps, # 容器帧率元数据
138
+ vframes=target_frames # 只导出前 N 帧
139
+ )
140
+ ffmpeg.run(out, overwrite_output=True, quiet=True)
141
+
142
+ print(f"Total videos: {len(frames)}, after filtering: {len(filtered_meta)}")
143
+
144
+ # 导出 jsonl
145
+ if not dryrun:
146
+ jsonl_file = output_root / f"processed_{split}.jsonl"
147
+ with jsonlines.open(jsonl_file, "w") as writer:
148
+ writer.write_all(filtered_meta)
149
+
150
+ # 创建输出目录
151
+ output_dir = Path(f"debug/summarize_camerabench_{split}")
152
+ output_dir.mkdir(parents=True, exist_ok=True)
153
+
154
+ # 1. labels 柱状图
155
+ if labels:
156
+ fig1, ax1 = plt.subplots(figsize=(8, 6))
157
+ label_names = list(labels.keys())
158
+ label_counts = [labels[k] for k in label_names]
159
+ ax1.bar(label_names, label_counts, color='skyblue')
160
+ ax1.set_xticklabels(label_names, rotation=45, ha='right')
161
+ ax1.set_ylabel('Count')
162
+ ax1.set_title('Label Frequencies')
163
+ plt.tight_layout()
164
+ fig1.savefig(output_dir / "label_frequencies.png", dpi=300, bbox_inches='tight')
165
+ plt.close(fig1)
166
+
167
+ # 2. frames 直方图
168
+ fig2, ax2 = plt.subplots(figsize=(8, 6))
169
+ ax2.hist(frames, bins=30, color='orange', edgecolor='black')
170
+ ax2.set_xlabel('Number of Frames')
171
+ ax2.set_ylabel('Frequency')
172
+ ax2.set_title('Distribution of Frames')
173
+ plt.tight_layout()
174
+ fig2.savefig(output_dir / "frames_distribution.png", dpi=300, bbox_inches='tight')
175
+ plt.close(fig2)
176
+
177
+ # 3. heights 直方图
178
+ fig3, ax3 = plt.subplots(figsize=(8, 6))
179
+ ax3.hist(heights, bins=30, color='green', edgecolor='black')
180
+ ax3.set_xlabel('Height (pixels)')
181
+ ax3.set_ylabel('Frequency')
182
+ ax3.set_title('Distribution of Frame Heights')
183
+ plt.tight_layout()
184
+ fig3.savefig(output_dir / "heights_distribution.png", dpi=300, bbox_inches='tight')
185
+ plt.close(fig3)
186
+
187
+ # 4. fps 直方图
188
+ fig4, ax4 = plt.subplots(figsize=(8, 6))
189
+ ax4.hist(fps_list, bins=30, color='purple', edgecolor='black')
190
+ ax4.set_xlabel('FPS (frames per second)')
191
+ ax4.set_ylabel('Frequency')
192
+ ax4.set_title('FPS Distribution')
193
+ plt.tight_layout()
194
+ fig4.savefig(output_dir / "fps_distribution.png", dpi=300, bbox_inches='tight')
195
+ plt.close(fig4)
UCPE/tools/process_panshot.py ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import jsonlines
3
+ from tqdm.auto import tqdm
4
+ from collections import defaultdict
5
+ import matplotlib.pyplot as plt
6
+ import ffmpeg
7
+ import json
8
+ import csv
9
+ import numpy as np
10
+ import cv2
11
+ from einops import rearrange, repeat
12
+ from visualize_pose import vis_to_html
13
+ import decord
14
+ from decord import VideoReader, cpu
15
+ from copy import deepcopy
16
+ from equilib import equi2pers
17
+ from thirdparty.PanFlow.utils.erp_utils import equilib_rotation
18
+ from scipy.spatial.transform import Rotation as R, Slerp
19
+ import shutil
20
+ import torch
21
+ import random
22
+ from concurrent.futures import ProcessPoolExecutor, as_completed
23
+ import yt_dlp
24
+ from threading import Lock
25
+ import gc
26
+
27
+
28
+ split = "train" # "train" or "test"
29
+ panflow_root = Path("data/360-1M")
30
+ pf_pose_root = panflow_root / "slam_pose"
31
+ ucpe_root = Path("data/UCPE")
32
+ camerabench_root = ucpe_root / "CameraBench"
33
+ match_pf_root = ucpe_root / "PanFlow" / f"match_to_camerabench-{split}"
34
+ pf_clip_root = ucpe_root / "PanFlow" / f"match_clips-{split}"
35
+ pf_video_root = ucpe_root / "PanFlow" / "videos"
36
+ ps_video_root = ucpe_root / "PanShot" / f"videos-{split}"
37
+ ps_video_root.mkdir(parents=True, exist_ok=True)
38
+ ps_pose_root = ucpe_root / "PanShot" / f"pose-{split}"
39
+ ps_pose_root.mkdir(parents=True, exist_ok=True)
40
+ ps_meta_root = ucpe_root / "PanShot" / f"meta-{split}"
41
+ ps_meta_root.mkdir(parents=True, exist_ok=True)
42
+ debug_root = Path("debug/process_panshot")
43
+
44
+ random_seed = 42
45
+ num_workers = 8
46
+ target_fps = 16
47
+ target_height = 480
48
+ target_width = 832
49
+
50
+ x_fovs = [
51
+ [90, 110], # 典型 pinhole / 准广角
52
+ [110, 140], # 广角(轻微畸变)
53
+ [140, 180], # 常见鱼眼(GoPro / 全景相机)
54
+ [160, 200], # 极鱼眼 / 安防全景
55
+ ]
56
+
57
+ xis = [
58
+ [0.0, 0.0], # pinhole
59
+ [0.5, 0.95], # 广角,畸变很小
60
+ [1.05, 2.0], # 常见鱼眼
61
+ [1.5, 2.3], # 极鱼眼
62
+ ]
63
+ rot_augs = {
64
+ "no_rot_aug": {
65
+ "num": 1,
66
+ },
67
+ "yaw_aug": {
68
+ "num": 1,
69
+ "fixed_yaw": 180,
70
+ },
71
+ "yaw_pitch_aug": {
72
+ "num": 1,
73
+ "fixed_yaw": 180,
74
+ "fixed_pitch": 80,
75
+ },
76
+ "linear_aug": {
77
+ "num": 1,
78
+ "fixed_yaw": 90,
79
+ "fixed_pitch": 40,
80
+ "fixed_roll": 30,
81
+ "linear_yaw": 90,
82
+ "linear_pitch": 40,
83
+ "linear_roll": 30,
84
+ },
85
+ }
86
+
87
+ visualize_ref = False
88
+ debug_aug = False
89
+ debug_aug_lower = False
90
+ debug = False
91
+ pose_only = False
92
+ overwrite = False
93
+ offline = False
94
+
95
+ clip_metas = list(pf_clip_root.glob("*.json"))
96
+ clip_metas.sort()
97
+ print(f"Found {len(clip_metas)} PanFlow clip files.")
98
+ video_metas = defaultdict(dict)
99
+ seed = random_seed
100
+ for clip_meta in clip_metas:
101
+ with open(clip_meta, "r") as f:
102
+ meta = json.load(f)
103
+ video_metas[meta["video_id"]][meta["clip_id"]] = meta
104
+ meta["matches"] = []
105
+ meta["seed"] = seed
106
+ seed += 1
107
+ print(f"Found {len(video_metas)} unique PanFlow videos.")
108
+
109
+ match_metas = list(match_pf_root.glob("*.json"))
110
+ match_metas.sort()
111
+ cb_poses = {}
112
+ num_matches = 0
113
+ for match_meta in tqdm(match_metas, desc="Loading match metas"):
114
+ with open(match_meta, "r") as f:
115
+ cb_matches = json.load(f)
116
+ cb_video = match_meta.stem
117
+
118
+ if cb_video not in cb_poses:
119
+ pose_file = camerabench_root / "pose" / f"{cb_video}.npy"
120
+ pose= np.load(pose_file) # (T, 4, 4)
121
+ cb_poses[cb_video] = pose
122
+
123
+ for cb_match in cb_matches:
124
+ matches = video_metas[cb_match['video_id']][cb_match['clip_id']]["matches"]
125
+ matches.append({
126
+ "match_id": len(matches),
127
+ "cb_video": cb_video,
128
+ "frames": cb_match["frames"],
129
+ "R": cb_match["R"],
130
+ })
131
+ num_matches += 1
132
+ print(f"Found {num_matches} matches to {len(cb_poses)} CameraBench videos.")
133
+
134
+ num_rot_augs = sum(aug["num"] for aug in rot_augs.values()) + 1
135
+ estimated_clips = num_rot_augs * num_matches
136
+ print(f"Estimated total {estimated_clips} PanShot clips to be generated.")
137
+
138
+
139
+ def apply_rotation_align(rotation, R):
140
+ """
141
+ 将旋转对齐矩阵 R 应用于相机轨迹的旋转矩阵序列。
142
+ 左乘 R: R'_cw = R * R_cw
143
+
144
+ Args:
145
+ rotation: (T, 3, 3) # 待对齐的旋转序列
146
+ R: (3, 3) # 对齐用的旋转矩阵
147
+
148
+ Returns:
149
+ rotation_aligned: (T, 3, 3)
150
+ """
151
+ # einsum: i j, t j k -> t i k
152
+ return np.einsum("ij,tjk->tik", R, rotation)
153
+
154
+
155
+ def make_mixed_rotation(rotation: np.ndarray,
156
+ yaw_end: float = 0.0,
157
+ pitch_end: float = 0.0,
158
+ roll_end: float = 0.0,
159
+ sample_frames: np.ndarray = None) -> np.ndarray:
160
+ """
161
+ 混合增强: yaw 在世界坐标系, pitch/roll 在相机坐标系
162
+ rotation: (T,3,3) 采样后的原始 R_cw
163
+ yaw_end/pitch_end/roll_end: 增强角度(度)
164
+ sample_frames: (T,) 原始帧索引; 如果提供, 将按首尾 idx 做连续插值后再采样
165
+ """
166
+
167
+ # === 确定插值时间轴 ===
168
+ if sample_frames is not None:
169
+ # 原始轨迹长度
170
+ start_idx = int(sample_frames[0])
171
+ end_idx = int(sample_frames[-1])
172
+ full_T = end_idx - start_idx + 1
173
+ full_times = np.linspace(0, 1, full_T)
174
+ else:
175
+ full_T = rotation.shape[0]
176
+ full_times = np.linspace(0, 1, full_T)
177
+
178
+ # ===== 1) 世界坐标系 yaw 插值 =====
179
+ R_yaw_start = R.identity()
180
+ R_yaw_end = R.from_euler('y', yaw_end, degrees=True)
181
+ slerp_yaw = Slerp([0, 1], R.from_matrix([R_yaw_start.as_matrix(), R_yaw_end.as_matrix()]))
182
+ R_yaw_full = slerp_yaw(full_times).as_matrix() # (full_T,3,3)
183
+
184
+ # ===== 2) 相机坐标系 pitch/roll 插值 =====
185
+ R_pr_start = R.identity()
186
+ R_pr_end = R.from_euler('xz', [pitch_end, roll_end], degrees=True)
187
+ slerp_pr = Slerp([0, 1], R.from_matrix([R_pr_start.as_matrix(), R_pr_end.as_matrix()]))
188
+ R_pr_full = slerp_pr(full_times).as_matrix() # (full_T,3,3)
189
+
190
+ # ===== 3) 对齐采样帧 =====
191
+ if sample_frames is not None:
192
+ # 通过偏移索引选出采样帧对应的旋转
193
+ idxs = (sample_frames - sample_frames[0]).astype(int)
194
+ R_yaw = R_yaw_full[idxs]
195
+ R_pr = R_pr_full[idxs]
196
+ else:
197
+ R_yaw = R_yaw_full
198
+ R_pr = R_pr_full
199
+
200
+ # ===== 4) 合成 =====
201
+ # yaw 世界系 → 左乘, pitch/roll 相机系 → 右乘
202
+ R_out = np.einsum('tij,tjk,tkl->til', R_yaw, rotation, R_pr)
203
+ return R_out
204
+
205
+
206
+ def download_video(video_id, output_path):
207
+ video_url = f"https://www.youtube.com/watch?v={video_id}"
208
+ ydl_opts = {
209
+ "outtmpl": str(output_path),
210
+ "format": "bestvideo[height<=2500][height>1500]",
211
+ "quiet": False,
212
+ "no_warnings": True,
213
+ "simulate": False,
214
+ "cookiefile": "~/.config/cookies.txt",
215
+ "print": [
216
+ "before_dl:Format: %(format_id)s | Res: %(resolution)s | FPS: %(fps)s",
217
+ "after_move:Size: %(filesize:.2fMB)s"
218
+ ],
219
+ "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
220
+ "retries": 3,
221
+ }
222
+
223
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
224
+ ydl.download([video_url])
225
+
226
+
227
+ download_lock = Lock()
228
+
229
+ def process_one_video(video_id, video_meta):
230
+ video_path = Path(pf_video_root) / f"{video_id}.mp4"
231
+ if not video_path.exists() and not pose_only:
232
+ if offline:
233
+ tqdm.write(f"Offline mode, skipping download of {video_id}.")
234
+ return False
235
+ try:
236
+ with download_lock:
237
+ download_video(video_id, video_path)
238
+ except Exception as e:
239
+ tqdm.write(f"Failed to download {video_id}: {e}")
240
+ return False
241
+
242
+ if video_path.exists():
243
+ for clip_meta in video_meta.values():
244
+ if not process_one_clip(clip_meta):
245
+ return False
246
+ return True
247
+
248
+
249
+ def process_one_clip(clip_meta):
250
+ video_id = clip_meta["video_id"]
251
+ clip_id = clip_meta["clip_id"]
252
+ pf_key = f"{video_id}-{clip_id}"
253
+ meta_file = ps_meta_root / f"{pf_key}.json"
254
+ if not overwrite and meta_file.exists():
255
+ tqdm.write(f"Meta file {meta_file} exists, skipping.")
256
+ return
257
+
258
+ seed = clip_meta["seed"]
259
+ np.random.seed(seed)
260
+ random.seed(seed)
261
+
262
+ video_file = pf_video_root / f"{video_id}.mp4"
263
+ try:
264
+ vr = VideoReader(str(video_file), ctx=cpu(0), num_threads=1)
265
+ except Exception as e:
266
+ tqdm.write(f"Failed to read video {video_file}: {e}")
267
+ return False
268
+ total_frames = len(vr)
269
+ del vr
270
+
271
+ matches = deepcopy(clip_meta["matches"])
272
+ for match in matches:
273
+ match_id = match["match_id"]
274
+ cb_video = match["cb_video"]
275
+ cb_pose = cb_poses[cb_video]
276
+ clip_start = clip_meta["frames"][0]
277
+ start_frame, end_frame = match["frames"]
278
+ start_frame += clip_start
279
+ end_frame += clip_start
280
+
281
+ if visualize_ref:
282
+ vis_file = debug_root / "cb_videos" / f"{pf_key}-{match_id}.mp4"
283
+ vis_file.parent.mkdir(parents=True, exist_ok=True)
284
+ if not vis_file.exists():
285
+ src_file = camerabench_root / "videos" / f"{cb_video}.mp4"
286
+ shutil.copy2(src_file, vis_file)
287
+
288
+ if end_frame >= total_frames:
289
+ tqdm.write(f"End frame {end_frame} > total frames {total_frames} in {video_file}, skipping.")
290
+ continue
291
+
292
+ num_frames_sampled = len(cb_pose)
293
+ sample_frames = np.linspace(start_frame, end_frame, num_frames_sampled)
294
+ sample_frames = np.round(sample_frames).astype(int)
295
+
296
+ if not pose_only:
297
+ vr = VideoReader(str(video_file), ctx=cpu(0), num_threads=1)
298
+ decord.bridge.set_bridge("torch")
299
+ erp_frames = vr.get_batch(sample_frames) # (T, H, W, 3)
300
+ del vr
301
+ gc.collect()
302
+ erp_frames = rearrange(erp_frames, "t h w c -> t c h w")
303
+
304
+ if visualize_ref:
305
+ out_path = debug_root / "pf_videos" / f"{pf_key}-{match_id}.mp4"
306
+ out_path.parent.mkdir(parents=True, exist_ok=True)
307
+
308
+ # ---- 1. 转换��� float 并降采样 ----
309
+ erp_down = torch.nn.functional.interpolate(
310
+ erp_frames.float(), # [1, T, C, H, W]
311
+ size=(target_height, target_width),
312
+ mode='bilinear',
313
+ align_corners=False
314
+ ).clamp(0, 255).to(torch.uint8)
315
+
316
+ # ---- 2. 改为 [T, H, W, C] ----
317
+ frames_np = rearrange(erp_down, "t c h w -> t h w c").contiguous().numpy()
318
+
319
+ # ---- 3. 写入视频 ----
320
+ process = (
321
+ ffmpeg
322
+ .input(
323
+ 'pipe:',
324
+ format='rawvideo',
325
+ pix_fmt='rgb24',
326
+ s=f'{target_width}x{target_height}',
327
+ framerate=target_fps
328
+ )
329
+ .output(str(out_path), vcodec='libx264', pix_fmt='yuv420p', crf=18, preset='slow')
330
+ .overwrite_output()
331
+ .run_async(pipe_stdin=True, quiet=True)
332
+ )
333
+ process.stdin.write(frames_np.tobytes())
334
+ process.stdin.close()
335
+ process.wait()
336
+ print(f"✅ saved to {out_path}")
337
+
338
+ clip_name = f"Clip-{clip_id:03d}"
339
+ pose_file = pf_pose_root / video_id / f"{clip_name}.npy"
340
+ pf_pose = np.load(pose_file) # (N, 3, 4)
341
+ c2w = pf_pose[sample_frames - clip_start] # (T, 3, 4)
342
+ last_row = repeat(np.array([0,0,0,1], dtype=c2w.dtype), "n -> t 1 n", t=c2w.shape[0])
343
+ c2w = np.concatenate([c2w, last_row], axis=-2) # (T, 4, 4)
344
+ w2c0 = np.linalg.inv(c2w[0]) # (4, 4)
345
+ c2w = w2c0[None] @ c2w # (T, 4, 4)
346
+ pf_pose = c2w[:, :3, :] # (T, 3, 4)
347
+
348
+ rotation = cb_pose[:, :3, :3] # (T, 3, 3)
349
+ R_align = np.array(match["R"]).T
350
+ rotation = apply_rotation_align(rotation, R_align)
351
+
352
+ videos = []
353
+ i_rot = 0
354
+ for aug_key, aug_setting in rot_augs.items():
355
+ for i_aug in range(aug_setting["num"]):
356
+ aug_meta = deepcopy(aug_setting)
357
+ del aug_meta["num"]
358
+
359
+ rotation_aug = rotation.copy()
360
+ if "fixed_yaw" in aug_setting:
361
+ yaw_aug = int(np.random.randint(-aug_setting["fixed_yaw"], aug_setting["fixed_yaw"]))
362
+ yaw_rad = float(np.deg2rad(yaw_aug))
363
+ aug_meta["yaw"] = yaw_aug
364
+ R_yaw_world = R.from_euler("y", yaw_rad, degrees=False).as_matrix()
365
+ rotation_aug = np.einsum("ij,tjk->tik", R_yaw_world, rotation_aug)
366
+ if "fixed_pitch" in aug_setting:
367
+ pitch_aug = int(np.random.randint(-aug_setting["fixed_pitch"], aug_setting["fixed_pitch"]))
368
+ pitch_rad = float(np.deg2rad(pitch_aug))
369
+ aug_meta["pitch"] = pitch_aug
370
+ R_pitch_cam = R.from_euler("x", pitch_rad, degrees=False).as_matrix()
371
+ rotation_aug = np.einsum("tij,jk->tik", rotation_aug, R_pitch_cam)
372
+ if "fixed_roll" in aug_setting:
373
+ roll_aug = int(np.random.randint(-aug_setting["fixed_roll"], aug_setting["fixed_roll"]))
374
+ roll_rad = float(np.deg2rad(roll_aug))
375
+ aug_meta["roll"] = roll_aug
376
+ R_roll_cam = R.from_euler("z", roll_rad, degrees=False).as_matrix()
377
+ rotation_aug = np.einsum("tij,jk->tik", rotation_aug, R_roll_cam)
378
+ if any(k in aug_setting for k in ("linear_yaw", "linear_pitch", "linear_roll")):
379
+ yaw_range = aug_setting.get("linear_yaw", 0)
380
+ pitch_rang = aug_setting.get("linear_pitch", 0)
381
+ roll_rang = aug_setting.get("linear_roll", 0)
382
+ yaw_end = int(np.random.randint(-yaw_range, yaw_range)) if yaw_range > 0 else 0
383
+ pitch_end = int(np.random.randint(-pitch_rang, pitch_rang)) if pitch_rang > 0 else 0
384
+ roll_end = int(np.random.randint(-roll_rang, roll_rang)) if roll_rang > 0 else 0
385
+ rotation_aug = make_mixed_rotation(rotation_aug, yaw_end, pitch_end, roll_end, sample_frames)
386
+ aug_meta.update({"linear_yaw": yaw_end, "linear_pitch": pitch_end, "linear_roll": roll_end})
387
+ # rotation_aug = repeat(np.eye(3), "i j -> t i j", t=rotation_aug.shape[0]) # Debug
388
+
389
+ # generate camera pose
390
+ ps_pose_file = Path(f"{pf_key}-{match_id}-{aug_key}_{i_aug}.npy")
391
+ ps_pose = pf_pose.copy()
392
+ ps_pose[..., :3] = pf_pose[..., :3] @ rotation_aug
393
+ np.save(ps_pose_root / ps_pose_file, ps_pose)
394
+
395
+ idx_lens = i_rot % len(x_fovs) if debug_aug else random.randint(0, len(x_fovs) - 1)
396
+ x_fov_range = x_fovs[idx_lens]
397
+ xi_range = xis[idx_lens]
398
+ if debug_aug:
399
+ if debug_aug_lower:
400
+ x_fov = x_fov_range[0]
401
+ xi = xi_range[0]
402
+ else:
403
+ x_fov = x_fov_range[1]
404
+ xi = xi_range[1]
405
+ else:
406
+ x_fov = int(np.round(np.random.uniform(*x_fov_range)))
407
+ xi = float(np.round(np.random.uniform(*xi_range), 2))
408
+
409
+ if not pose_only:
410
+ rotation_aug = equilib_rotation(rotation_aug)
411
+ pers_frames = equi2pers(erp_frames, rotation_aug, target_height, target_width, x_fov, xi=xi)
412
+ pers_frames = rearrange(pers_frames, "t c h w -> t h w c")
413
+ pers_frames = pers_frames.numpy()
414
+
415
+ out_file = ps_video_root / f"{pf_key}-{match_id}-{aug_key}_{i_aug}-fov{x_fov}-xi{xi:.2f}.mp4"
416
+ process = (
417
+ ffmpeg
418
+ .input('pipe:', format='rawvideo', pix_fmt='rgb24', s=f'{target_width}x{target_height}', framerate=target_fps)
419
+ .output(str(out_file), pix_fmt='yuv420p', vcodec='libx264', r=target_fps, crf=16, preset='slow')
420
+ .overwrite_output()
421
+ .run_async(pipe_stdin=True, quiet=True)
422
+ )
423
+ process.stdin.write(pers_frames.tobytes())
424
+ del pers_frames
425
+ process.stdin.close()
426
+ process.wait()
427
+
428
+ videos.append({
429
+ "video": str(out_file.stem),
430
+ "pose": str(ps_pose_file.stem),
431
+ "x_fov": x_fov,
432
+ "xi": xi,
433
+ "rot_aug": aug_meta,
434
+ })
435
+
436
+ gc.collect()
437
+ torch.cuda.empty_cache()
438
+
439
+ i_rot += 1
440
+
441
+ match["videos"] = videos
442
+
443
+ if not pose_only:
444
+ with open(meta_file, "w") as f:
445
+ json.dump(matches, f, indent=4)
446
+
447
+ return True
448
+
449
+ success = []
450
+ if debug:
451
+ # === 单线程调试模式 ===
452
+ for video_id, video_meta in tqdm(
453
+ video_metas.items(),
454
+ desc="Processing clips (debug single-thread)"
455
+ ):
456
+ success.append(process_one_video(video_id, video_meta))
457
+ else:
458
+ # === 正常并行模式 ===
459
+ with ProcessPoolExecutor(max_workers=num_workers) as ex:
460
+ futures = [
461
+ ex.submit(process_one_video, video_id, video_meta)
462
+ for video_id, video_meta in video_metas.items()
463
+ ]
464
+ for fut in tqdm(as_completed(futures), total=len(futures), desc="Processing clips"):
465
+ try:
466
+ success.append(fut.result())
467
+ except Exception as e:
468
+ print("Error:", e)
469
+
470
+ print(f"All done. {sum(success)}/{len(success)} videos processed successfully.")
UCPE/tools/rerender_panshot.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Re-render PanShot videos at 704x1280 using existing meta/pose files
3
+ and CameraBench poses. Downloads YouTube source videos on-the-fly.
4
+
5
+ Usage:
6
+ conda activate UCPE
7
+ python tools/rerender_panshot.py
8
+
9
+ Prerequisites:
10
+ - PanShot meta files at {panshot_root}/meta-{split}/
11
+ - CameraBench poses at data/UCPE/CameraBench/pose/
12
+ - YouTube cookies at ~/.config/cookies.txt
13
+ """
14
+
15
+ from pathlib import Path
16
+ import json
17
+ import numpy as np
18
+ import torch
19
+ import ffmpeg
20
+ import gc
21
+ from einops import rearrange
22
+ from tqdm.auto import tqdm
23
+ from collections import defaultdict
24
+ from concurrent.futures import ProcessPoolExecutor, as_completed
25
+ from threading import Lock
26
+ from scipy.spatial.transform import Rotation as R, Slerp
27
+ import yt_dlp
28
+ import decord
29
+ from decord import VideoReader, cpu
30
+ from equilib import equi2pers
31
+ from thirdparty.PanFlow.utils.erp_utils import equilib_rotation
32
+
33
+
34
+ # ===== Configuration =====
35
+ split = "test" # "train" or "test"
36
+ panshot_root = Path("/tmp/data/UCPE/PanShot")
37
+ camerabench_root = Path("data/UCPE/CameraBench")
38
+ video_cache_root = Path("youtube_cache")
39
+ output_video_root = panshot_root / f"videos_704-{split}"
40
+ persistent_video_root = Path(f"data/UCPE/PanShot/videos_704-{split}") # persistent copy
41
+ meta_root = panshot_root / f"meta-{split}"
42
+
43
+ target_fps = 16
44
+ target_height = 704
45
+ target_width = 1280
46
+ num_workers = 16 # parallel workers
47
+ overwrite = False
48
+
49
+ video_cache_root.mkdir(parents=True, exist_ok=True)
50
+ output_video_root.mkdir(parents=True, exist_ok=True)
51
+ persistent_video_root.mkdir(parents=True, exist_ok=True)
52
+
53
+
54
+ # ===== Helper functions =====
55
+
56
+ def download_video(video_id, output_path):
57
+ video_url = f"https://www.youtube.com/watch?v={video_id}"
58
+ ydl_opts = {
59
+ "outtmpl": str(output_path),
60
+ "format": "bestvideo[height>=1080]",
61
+ "quiet": False,
62
+ "no_warnings": True,
63
+ "simulate": False,
64
+ "cookiefile": str(Path("~/.config/cookies.txt").expanduser()),
65
+ "print": [
66
+ "before_dl:Format: %(format_id)s | Res: %(resolution)s | FPS: %(fps)s",
67
+ ],
68
+ "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
69
+ "retries": 3,
70
+ }
71
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
72
+ ydl.download([video_url])
73
+
74
+
75
+ def apply_rotation_align(rotation, R_mat):
76
+ return np.einsum("ij,tjk->tik", R_mat, rotation)
77
+
78
+
79
+ def make_mixed_rotation(rotation, yaw_end, pitch_end, roll_end, sample_frames):
80
+ if sample_frames is not None:
81
+ start_idx = int(sample_frames[0])
82
+ end_idx = int(sample_frames[-1])
83
+ full_T = end_idx - start_idx + 1
84
+ full_times = np.linspace(0, 1, full_T)
85
+ else:
86
+ full_T = rotation.shape[0]
87
+ full_times = np.linspace(0, 1, full_T)
88
+
89
+ R_yaw_start = R.identity()
90
+ R_yaw_end_r = R.from_euler('y', yaw_end, degrees=True)
91
+ slerp_yaw = Slerp([0, 1], R.from_matrix([R_yaw_start.as_matrix(), R_yaw_end_r.as_matrix()]))
92
+ R_yaw_full = slerp_yaw(full_times).as_matrix()
93
+
94
+ R_pr_start = R.identity()
95
+ R_pr_end_r = R.from_euler('xz', [pitch_end, roll_end], degrees=True)
96
+ slerp_pr = Slerp([0, 1], R.from_matrix([R_pr_start.as_matrix(), R_pr_end_r.as_matrix()]))
97
+ R_pr_full = slerp_pr(full_times).as_matrix()
98
+
99
+ if sample_frames is not None:
100
+ idxs = (sample_frames - sample_frames[0]).astype(int)
101
+ R_yaw = R_yaw_full[idxs]
102
+ R_pr = R_pr_full[idxs]
103
+ else:
104
+ R_yaw = R_yaw_full
105
+ R_pr = R_pr_full
106
+
107
+ R_out = np.einsum('tij,tjk,tkl->til', R_yaw, rotation, R_pr)
108
+ return R_out
109
+
110
+
111
+ def reconstruct_rotation_aug(base_rotation, rot_aug, sample_frames):
112
+ """Reconstruct rotation_aug from base rotation + stored augmentation params."""
113
+ rotation_aug = base_rotation.copy()
114
+
115
+ if "yaw" in rot_aug:
116
+ yaw_rad = float(np.deg2rad(rot_aug["yaw"]))
117
+ R_yaw_world = R.from_euler("y", yaw_rad, degrees=False).as_matrix()
118
+ rotation_aug = np.einsum("ij,tjk->tik", R_yaw_world, rotation_aug)
119
+
120
+ if "pitch" in rot_aug:
121
+ pitch_rad = float(np.deg2rad(rot_aug["pitch"]))
122
+ R_pitch_cam = R.from_euler("x", pitch_rad, degrees=False).as_matrix()
123
+ rotation_aug = np.einsum("tij,jk->tik", rotation_aug, R_pitch_cam)
124
+
125
+ if "roll" in rot_aug:
126
+ roll_rad = float(np.deg2rad(rot_aug["roll"]))
127
+ R_roll_cam = R.from_euler("z", roll_rad, degrees=False).as_matrix()
128
+ rotation_aug = np.einsum("tij,jk->tik", rotation_aug, R_roll_cam)
129
+
130
+ if any(k in rot_aug for k in ("linear_yaw", "linear_pitch", "linear_roll")):
131
+ yaw_end = rot_aug.get("linear_yaw", 0)
132
+ pitch_end = rot_aug.get("linear_pitch", 0)
133
+ roll_end = rot_aug.get("linear_roll", 0)
134
+ rotation_aug = make_mixed_rotation(rotation_aug, yaw_end, pitch_end, roll_end, sample_frames)
135
+
136
+ return rotation_aug
137
+
138
+
139
+ download_lock = Lock()
140
+
141
+
142
+ def process_one_video(video_id, clips_info):
143
+ """Download YouTube video and re-render all its clips."""
144
+ video_path = video_cache_root / f"{video_id}.mp4"
145
+
146
+ if not video_path.exists():
147
+ try:
148
+ with download_lock:
149
+ if not video_path.exists(): # double check after lock
150
+ download_video(video_id, video_path)
151
+ except Exception as e:
152
+ tqdm.write(f"Failed to download {video_id}: {e}")
153
+ return 0
154
+
155
+ if not video_path.exists():
156
+ return 0
157
+
158
+ rendered = 0
159
+ for clip_info in clips_info:
160
+ try:
161
+ rendered += process_one_clip(video_id, video_path, clip_info)
162
+ except Exception as e:
163
+ tqdm.write(f"Error processing {clip_info['meta_stem']}: {e}")
164
+ import traceback
165
+ traceback.print_exc()
166
+
167
+ return rendered
168
+
169
+
170
+ def process_one_clip(video_id, video_path, clip_info):
171
+ """Re-render all video variants for one clip at the target resolution."""
172
+ meta_stem = clip_info["meta_stem"]
173
+ matches = clip_info["matches"]
174
+ rendered = 0
175
+
176
+ # Read source ERP video metadata
177
+ try:
178
+ vr = VideoReader(str(video_path), ctx=cpu(0), num_threads=1)
179
+ except Exception as e:
180
+ tqdm.write(f"Failed to read {video_path}: {e}")
181
+ return 0
182
+ total_frames = len(vr)
183
+ del vr
184
+
185
+ for match in matches:
186
+ cb_video = match["cb_video"]
187
+ R_align = np.array(match["R"]).T
188
+ start_frame, end_frame = match["frames"]
189
+
190
+ if end_frame >= total_frames:
191
+ tqdm.write(f"End frame {end_frame} >= total {total_frames} for {video_id}, skipping match.")
192
+ continue
193
+
194
+ # Load CameraBench pose to get num_frames and base rotation
195
+ cb_pose_file = camerabench_root / "pose" / f"{cb_video}.npy"
196
+ if not cb_pose_file.exists():
197
+ tqdm.write(f"CameraBench pose not found: {cb_pose_file}, skipping.")
198
+ continue
199
+ cb_pose = np.load(cb_pose_file) # (T, 4, 4)
200
+
201
+ num_frames = len(cb_pose)
202
+ sample_frames = np.linspace(start_frame, end_frame, num_frames)
203
+ sample_frames = np.round(sample_frames).astype(int)
204
+
205
+ # Base rotation: CameraBench rotation aligned
206
+ base_rotation = cb_pose[:, :3, :3] # (T, 3, 3)
207
+ base_rotation = apply_rotation_align(base_rotation, R_align)
208
+
209
+ # Read ERP frames (shared across all variants of this match)
210
+ erp_frames = None
211
+
212
+ for video_info in match.get("videos", []):
213
+ video_name = video_info["video"]
214
+ out_file = output_video_root / f"{video_name}.mp4"
215
+
216
+ persistent_file = persistent_video_root / f"{video_name}.mp4"
217
+ if not overwrite and (out_file.exists() or persistent_file.exists()):
218
+ rendered += 1
219
+ continue
220
+
221
+ x_fov = video_info["x_fov"]
222
+ xi = video_info["xi"]
223
+ rot_aug = video_info.get("rot_aug", {})
224
+
225
+ # Reconstruct rotation_aug
226
+ rotation_aug = reconstruct_rotation_aug(base_rotation, rot_aug, sample_frames)
227
+
228
+ # Lazy load ERP frames
229
+ if erp_frames is None:
230
+ vr = VideoReader(str(video_path), ctx=cpu(0), num_threads=1)
231
+ decord.bridge.set_bridge("torch")
232
+ erp_frames = vr.get_batch(sample_frames) # (T, H, W, 3)
233
+ del vr
234
+ gc.collect()
235
+ erp_frames = rearrange(erp_frames, "t h w c -> t c h w")
236
+
237
+ # Project ERP to perspective
238
+ rot_for_equi2pers = equilib_rotation(rotation_aug)
239
+ pers_frames = equi2pers(erp_frames, rot_for_equi2pers, target_height, target_width, x_fov, xi=xi)
240
+ pers_frames = rearrange(pers_frames, "t c h w -> t h w c")
241
+ pers_frames = pers_frames.numpy()
242
+
243
+ # Write video
244
+ out_file.parent.mkdir(parents=True, exist_ok=True)
245
+ process = (
246
+ ffmpeg
247
+ .input('pipe:', format='rawvideo', pix_fmt='rgb24',
248
+ s=f'{target_width}x{target_height}', framerate=target_fps)
249
+ .output(str(out_file), pix_fmt='yuv420p', vcodec='libx264',
250
+ r=target_fps, crf=16, preset='fast')
251
+ .overwrite_output()
252
+ .run_async(pipe_stdin=True, quiet=True)
253
+ )
254
+ process.stdin.write(pers_frames.tobytes())
255
+ del pers_frames
256
+ process.stdin.close()
257
+ process.wait()
258
+
259
+ # Save persistent copy
260
+ import shutil
261
+ persistent_file = persistent_video_root / f"{video_name}.mp4"
262
+ if not persistent_file.exists():
263
+ shutil.copy2(str(out_file), str(persistent_file))
264
+
265
+ rendered += 1
266
+
267
+ gc.collect()
268
+ torch.cuda.empty_cache()
269
+
270
+ if erp_frames is not None:
271
+ del erp_frames
272
+ gc.collect()
273
+
274
+ return rendered
275
+
276
+
277
+ # ===== Main =====
278
+
279
+ print(f"Loading meta files from {meta_root}...")
280
+ meta_files = sorted(meta_root.glob("*.json"))
281
+ print(f"Found {len(meta_files)} meta files.")
282
+
283
+ # Group by YouTube video ID (first 11 chars of meta filename)
284
+ video_clips = defaultdict(list)
285
+ total_variants = 0
286
+ for meta_file in meta_files:
287
+ with open(meta_file) as f:
288
+ matches = json.load(f)
289
+ stem = meta_file.stem
290
+ youtube_id = stem[:11]
291
+ for match in matches:
292
+ total_variants += len(match.get("videos", []))
293
+ video_clips[youtube_id].append({
294
+ "meta_stem": stem,
295
+ "matches": matches,
296
+ })
297
+
298
+ print(f"Found {len(video_clips)} unique YouTube videos, {total_variants} total variants to render.")
299
+
300
+ # Check how many already exist
301
+ existing = sum(1 for f in output_video_root.glob("*.mp4"))
302
+ print(f"Already rendered: {existing}")
303
+
304
+ # Process
305
+ if num_workers <= 1:
306
+ for video_id, clips in tqdm(sorted(video_clips.items()), desc="Processing"):
307
+ process_one_video(video_id, clips)
308
+ else:
309
+ with ProcessPoolExecutor(max_workers=num_workers) as executor:
310
+ futures = {
311
+ executor.submit(process_one_video, video_id, clips): video_id
312
+ for video_id, clips in sorted(video_clips.items())
313
+ }
314
+ pbar = tqdm(total=len(futures), desc="Processing videos")
315
+ total_rendered = 0
316
+ for future in as_completed(futures):
317
+ video_id = futures[future]
318
+ try:
319
+ n = future.result()
320
+ total_rendered += n
321
+ except Exception as e:
322
+ tqdm.write(f"Error on {video_id}: {e}")
323
+ pbar.update(1)
324
+ pbar.set_postfix(rendered=total_rendered)
325
+ pbar.close()
326
+
327
+ final_count = sum(1 for f in output_video_root.glob("*.mp4"))
328
+ print(f"Done. Total rendered videos: {final_count}")
UCPE/tools/score_panflow.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from q_align import QAlignVideoScorer, QAlignAestheticScorer, QAlignScorer
2
+ from pathlib import Path
3
+ from tqdm.auto import tqdm
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ import os
6
+ import json
7
+ from decord import VideoReader, cpu
8
+ import jsonlines
9
+ from PIL import Image
10
+ import torch
11
+ import numpy as np
12
+
13
+ # -----------------------------
14
+ # basic configuration
15
+ # -----------------------------
16
+ panflow_root = Path("data/360-1M")
17
+ panshot_root = Path("data/UCPE")
18
+ output_root = panshot_root / "PanFlow"
19
+ output_root.mkdir(parents=True, exist_ok=True)
20
+ output_jsonl = output_root / "scores.jsonl"
21
+ batch_size = 16
22
+ max_workers = min(64, os.cpu_count() or 4)
23
+ inflight_limit = max_workers * 2
24
+ max_frames = 10
25
+ print(f"Using max_workers={max_workers}, inflight_limit={inflight_limit}")
26
+
27
+ meta_root = panflow_root / "meta"
28
+ meta_files = list((meta_root).glob("*.json"))
29
+ meta_files.sort()
30
+ print(f"Found {len(meta_files)} PanFlow meta files.")
31
+
32
+ def load_one_meta(meta_file):
33
+ try:
34
+ with open(meta_file, "r") as f:
35
+ meta = json.load(f)
36
+ video_id = meta.get("video_id", Path(meta_file).stem)
37
+ video_path = panflow_root / "videos" / f"{video_id}.mp4"
38
+
39
+ if "slam_clips" not in meta or "clips" not in meta["slam_clips"]:
40
+ return [] # skip
41
+ if not video_path.exists():
42
+ return [] # skip
43
+
44
+ clips_local = []
45
+ for clip in meta["slam_clips"]["clips"]:
46
+ clips_local.append({
47
+ "clip_id": clip["clip_id"],
48
+ "frames": clip["frames"],
49
+ "video_id": video_id,
50
+ "video_path": str(video_path),
51
+ })
52
+ return clips_local
53
+ except Exception as e:
54
+ tqdm.write(f"Error reading {meta_file}: {e}")
55
+ return []
56
+
57
+
58
+ # ================================================================
59
+ # 已处理 clip 检查
60
+ # ================================================================
61
+ processed = set()
62
+ if output_jsonl.exists():
63
+ print(f"Resuming from {output_jsonl}")
64
+ with open(output_jsonl, "r", encoding="utf-8") as f_in:
65
+ for line in f_in:
66
+ try:
67
+ rec = json.loads(line)
68
+ processed.add((rec["video_id"], rec["clip_id"]))
69
+ except Exception:
70
+ continue
71
+ print(f"Found {len(processed)} processed clips to skip.")
72
+
73
+
74
+ # -----------------------------
75
+ # 并行加载 meta 文件
76
+ # -----------------------------
77
+ clips = []
78
+ with ThreadPoolExecutor(max_workers=8) as ex:
79
+ futures = [ex.submit(load_one_meta, mf) for mf in meta_files]
80
+ for fut in tqdm(as_completed(futures), total=len(futures), desc="Loading PanFlow metadata"):
81
+ clips.extend(fut.result())
82
+
83
+ # 过滤掉已处理的 clips
84
+ clips = [c for c in clips if (c["video_id"], c["clip_id"]) not in processed]
85
+ print(f"Total {len(clips)} remaining clips from {len(meta_files)} videos.")
86
+
87
+
88
+ # -----------------------------
89
+ # scorer 初始化
90
+ # -----------------------------
91
+ video_scorer = QAlignVideoScorer()
92
+ scorers = {
93
+ "image_aesthetic": QAlignAestheticScorer(
94
+ tokenizer=video_scorer.tokenizer,
95
+ model=video_scorer.model,
96
+ image_processor=video_scorer.image_processor
97
+ ),
98
+ "image_quality": QAlignScorer(
99
+ tokenizer=video_scorer.tokenizer,
100
+ model=video_scorer.model,
101
+ image_processor=video_scorer.image_processor
102
+ ),
103
+ "video_quality": video_scorer,
104
+ }
105
+
106
+ # ================================================================
107
+ # util functions
108
+ # ================================================================
109
+ def process_one_clip(clip):
110
+ """
111
+ 从 clip 中读取指定起止帧范围内的帧(1fps),返回 {clip, frames(list of PIL)}.
112
+ """
113
+ video_path = clip["video_path"]
114
+ frame_range = clip.get("frames", None)
115
+
116
+ try:
117
+ vr = VideoReader(video_path, ctx=cpu(0), num_threads=1)
118
+ except Exception as e:
119
+ print(f"[process_one_clip] cannot open {video_path}: {e}")
120
+ return None
121
+
122
+ fps = vr.get_avg_fps()
123
+ start, end = frame_range[0], frame_range[-1]
124
+ start = max(0, start)
125
+ end = min(len(vr) - 1, end)
126
+
127
+ # 按 1fps 抽帧
128
+ frame_count = min((end - start + 1) / fps, max_frames)
129
+ frame_indices = np.linspace(start, end, num=max(int(frame_count), 1))
130
+ frame_indices = np.round(frame_indices).astype(int)
131
+
132
+ try:
133
+ frames_np = vr.get_batch(frame_indices).asnumpy()
134
+ except Exception as e:
135
+ print(f"[process_one_clip] error decoding {video_path}: {e}")
136
+ return None
137
+
138
+ frames = [Image.fromarray(frames_np[i]) for i in range(frames_np.shape[0])]
139
+ video = [video_scorer.expand2square(frame, tuple(int(x*255) for x in video_scorer.image_processor.image_mean)) for frame in frames]
140
+ video_tensors = video_scorer.image_processor.preprocess(video, return_tensors="pt")["pixel_values"].half()
141
+ return {"clip": clip, "frames": video_tensors}
142
+
143
+
144
+ def infer_and_flush(results, writer):
145
+ """
146
+ 批量推理并写出结果。
147
+ results: list of {clip, frames}
148
+ """
149
+ if not results:
150
+ return
151
+
152
+ video_batch = [r["frames"] for r in results]
153
+ scores = {}
154
+ with torch.inference_mode():
155
+ video_tensors = [vid.to(video_scorer.model.device) for vid in video_batch]
156
+ video_frames = [len(vid) for vid in video_tensors]
157
+ for key, scorer in scorers.items():
158
+ # image 分支拼接所有帧,video 分支传列表
159
+ images = torch.cat(video_tensors) if "image" in key else video_tensors
160
+ output_logits = scorer.model(
161
+ scorer.input_ids.repeat(len(images), 1),
162
+ images=images
163
+ )["logits"][:, -1, scorer.preferential_ids_]
164
+
165
+ values = torch.softmax(output_logits, -1) @ scorer.weight_tensor
166
+
167
+ if "image" in key:
168
+ # 按每个 clip 的帧数切开并取均值
169
+ values_split = torch.split(values, video_frames)
170
+ values = torch.stack([v.mean(0) for v in values_split])
171
+
172
+ scores[key] = values # shape: [n_clips]
173
+
174
+ # 遍历每个 clip,把三个分数写出
175
+ n_clips = len(results)
176
+ for i in range(n_clips):
177
+ clip_info = results[i]["clip"]
178
+ out_obj = {
179
+ "video_id": clip_info.get("video_id"),
180
+ "clip_id": clip_info.get("clip_id"),
181
+ }
182
+ for key, value in scores.items():
183
+ out_obj[key] = float(value[i].item())
184
+ writer.write(out_obj)
185
+
186
+
187
+ # ================================================================
188
+ # 主流程
189
+ # ================================================================
190
+ output_jsonl.parent.mkdir(parents=True, exist_ok=True)
191
+ prepared_buffer = []
192
+
193
+ try:
194
+ f = open(output_jsonl, "a", buffering=1, encoding="utf-8")
195
+ writer = jsonlines.Writer(f)
196
+
197
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
198
+ pbar = tqdm(total=len(clips), desc="processing clips")
199
+
200
+ pending = set()
201
+ i_submit = 0
202
+
203
+ # 先提交一些任务
204
+ while i_submit < len(clips) and len(pending) < inflight_limit:
205
+ fut = ex.submit(process_one_clip, clips[i_submit])
206
+ pending.add(fut)
207
+ i_submit += 1
208
+
209
+ while pending:
210
+ for fut in as_completed(list(pending), timeout=None):
211
+ pending.remove(fut)
212
+ result = fut.result()
213
+ if result is not None:
214
+ prepared_buffer.append(result)
215
+
216
+ # 满一批就推理一次
217
+ if len(prepared_buffer) >= batch_size:
218
+ infer_and_flush(prepared_buffer[:batch_size], writer)
219
+ prepared_buffer = prepared_buffer[batch_size:]
220
+ pbar.update(batch_size)
221
+
222
+ # 提交新的任务
223
+ while i_submit < len(clips) and len(pending) < inflight_limit:
224
+ fut_new = ex.submit(process_one_clip, clips[i_submit])
225
+ pending.add(fut_new)
226
+ i_submit += 1
227
+ break
228
+
229
+ # flush 剩余 buffer
230
+ while prepared_buffer:
231
+ chunk = prepared_buffer[:batch_size]
232
+ infer_and_flush(chunk, writer)
233
+ prepared_buffer = prepared_buffer[len(chunk):]
234
+
235
+ pbar.close()
236
+ finally:
237
+ try:
238
+ writer.close()
239
+ except Exception:
240
+ pass
241
+ try:
242
+ f.close()
243
+ except Exception:
244
+ pass
UCPE/tools/visualize_pose.py ADDED
@@ -0,0 +1,1231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Camera Pose Visualization Module
4
+
5
+ This module provides comprehensive tools for visualizing camera poses and trajectories
6
+ in 3D space using Plotly. It supports both static and animated visualizations with
7
+ automatic camera view optimization.
8
+ """
9
+
10
+ import argparse
11
+ import matplotlib
12
+ import matplotlib.pyplot as plt
13
+ import numpy as np
14
+ import os
15
+ import plotly.graph_objs as go
16
+ import plotly.io as pio
17
+ from tqdm import tqdm
18
+ import einops
19
+ import torch
20
+ from einops import repeat
21
+
22
+ # Use non-interactive backend for matplotlib to avoid display issues
23
+ matplotlib.use("agg")
24
+
25
+
26
+ class Pose:
27
+ """
28
+ A class of operations on camera poses (numpy arrays with shape [...,3,4]).
29
+ Each [3,4] camera pose takes the form of [R|t].
30
+ """
31
+
32
+ def __call__(self, R=None, t=None):
33
+ """
34
+ Construct a camera pose from the given rotation matrix R and/or translation vector t.
35
+ """
36
+ assert R is not None or t is not None
37
+ if R is None:
38
+ if not isinstance(t, np.ndarray):
39
+ t = np.array(t)
40
+ R = np.eye(3).repeat(*t.shape[:-1], 1, 1)
41
+ elif t is None:
42
+ if not isinstance(R, np.ndarray):
43
+ R = np.array(R)
44
+ t = np.zeros(R.shape[:-1])
45
+ else:
46
+ if not isinstance(R, np.ndarray):
47
+ R = np.array(R)
48
+ if not isinstance(t, np.ndarray):
49
+ t = np.array(t)
50
+ assert R.shape[:-1] == t.shape and R.shape[-2:] == (3, 3)
51
+ R = R.astype(np.float32)
52
+ t = t.astype(np.float32)
53
+ pose = np.concatenate([R, t[..., None]], axis=-1) # [...,3,4]
54
+ assert pose.shape[-2:] == (3, 4)
55
+ return pose
56
+
57
+ def invert(self, pose, use_inverse=False):
58
+ """
59
+ Invert a camera pose.
60
+ """
61
+ R, t = pose[..., :3], pose[..., 3:]
62
+ R_inv = np.linalg.inv(R) if use_inverse else R.transpose(0, 2, 1)
63
+ t_inv = (-R_inv @ t)[..., 0]
64
+ pose_inv = self(R=R_inv, t=t_inv)
65
+ return pose_inv
66
+
67
+ def compose(self, pose_list):
68
+ """
69
+ Compose a sequence of poses together.
70
+ pose_new(x) = poseN o ... o pose2 o pose1(x)
71
+ """
72
+ pose_new = pose_list[0]
73
+ for pose in pose_list[1:]:
74
+ pose_new = self.compose_pair(pose_new, pose)
75
+ return pose_new
76
+
77
+ def compose_pair(self, pose_a, pose_b):
78
+ """
79
+ Compose two poses together.
80
+ """
81
+ R_a, t_a = pose_a[..., :3], pose_a[..., 3:]
82
+ R_b, t_b = pose_b[..., :3], pose_b[..., 3:]
83
+ R_new = R_b @ R_a
84
+ t_new = (R_b @ t_a + t_b)[..., 0]
85
+ pose_new = self(R=R_new, t=t_new)
86
+ return pose_new
87
+
88
+ def scale_center(self, pose, scale):
89
+ """
90
+ Scale the camera center from the origin.
91
+ 0 = R@c+t --> c = -R^T@t (camera center in world coordinates)
92
+ 0 = R@(sc)+t' --> t' = -R@(sc) = -R@(-R^T@st) = st
93
+ """
94
+ R, t = pose[..., :3], pose[..., 3:]
95
+ pose_new = np.concatenate([R, t * scale], axis=-1)
96
+ return pose_new
97
+
98
+
99
+ def to_hom(X):
100
+ """
101
+ Convert points to homogeneous coordinates by appending ones.
102
+ """
103
+ X_hom = np.concatenate([X, np.ones_like(X[..., :1])], axis=-1)
104
+ return X_hom
105
+
106
+
107
+ def cam2world(X, pose):
108
+ """
109
+ Transform points from camera coordinates to world coordinates.
110
+ """
111
+ X_hom = to_hom(X)
112
+ pose_inv = Pose().invert(pose)
113
+ return X_hom @ pose_inv.transpose(0, 2, 1)
114
+
115
+
116
+ def get_camera_mesh(pose, depth=1):
117
+ """
118
+ Create a 3D mesh representation of camera frustums for visualization.
119
+ """
120
+ # Define camera frustum geometry: 4 corners of image plane + camera center
121
+ vertices = (
122
+ np.array(
123
+ [[-0.5, -0.5, 1], [0.5, -0.5, 1], [0.5, 0.5, 1], [-0.5, 0.5, 1], [0, 0, 0]]
124
+ )
125
+ * depth
126
+ ) # Shape: [5, 3] - 4 image plane corners + camera center
127
+
128
+ # Define triangular faces for the camera frustum mesh
129
+ faces = np.array(
130
+ [[0, 1, 2], [0, 2, 3], [0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]]
131
+ ) # Shape: [6, 3] - 6 triangular faces forming the pyramid
132
+
133
+ # Transform vertices from camera space to world space
134
+ vertices = cam2world(vertices[None], pose) # Shape: [N, 5, 3]
135
+
136
+ # Create wireframe lines connecting: corners -> center -> next corner
137
+ wireframe = vertices[:, [0, 1, 2, 3, 0, 4, 1, 2, 4, 3]] # Shape: [N, 10, 3]
138
+
139
+ return vertices, faces, wireframe
140
+
141
+
142
+ # def merge_xyz_indicators_plotly(xyz):
143
+ # """Merge xyz coordinate indicators for plotly visualization."""
144
+ # xyz = xyz[:, [[-1, 0], [-1, 1], [-1, 2]]] # [N,3,2,3]
145
+ # xyz_0, xyz_1 = unbind_np(xyz, axis=2) # [N,3,3]
146
+ # xyz_dummy = xyz_0 * np.nan
147
+ # xyz_merged = np.stack([xyz_0, xyz_1, xyz_dummy], axis=2) # [N,3,3,3]
148
+ # xyz_merged = xyz_merged.reshape(-1, 3)
149
+ # return xyz_merged
150
+
151
+
152
+ # def get_xyz_indicators(pose, length=0.1):
153
+ # """Get xyz coordinate axis indicators for a camera pose."""
154
+ # xyz = np.eye(4, 3)[None] * length
155
+ # xyz = cam2world(xyz, pose)
156
+ # return xyz
157
+
158
+
159
+ def merge_wireframes_plotly(wireframe):
160
+ """
161
+ Merge camera wireframes for efficient Plotly visualization.
162
+ """
163
+ wf_dummy = wireframe[:, :1] * np.nan # Create NaN separators
164
+ wireframe_merged = np.concatenate([wireframe, wf_dummy], axis=1).reshape(-1, 3)
165
+ return wireframe_merged
166
+
167
+
168
+ def merge_meshes(vertices, faces):
169
+ """
170
+ Merge multiple camera meshes into a single mesh for efficient rendering.
171
+ """
172
+ mesh_N, vertex_N = vertices.shape[:2]
173
+ # Adjust face indices for each mesh by adding vertex offset
174
+ faces_merged = np.concatenate([faces + i * vertex_N for i in range(mesh_N)], axis=0)
175
+ # Flatten all vertices into single array
176
+ vertices_merged = vertices.reshape(-1, vertices.shape[-1])
177
+ return vertices_merged, faces_merged
178
+
179
+
180
+ def unbind_np(array, axis=0):
181
+ """
182
+ Split numpy array along specified axis into a list of arrays.
183
+ """
184
+ if axis == 0:
185
+ return [array[i, :] for i in range(array.shape[0])]
186
+ elif axis == 1 or (len(array.shape) == 2 and axis == -1):
187
+ return [array[:, j] for j in range(array.shape[1])]
188
+ elif axis == 2 or (len(array.shape) == 3 and axis == -1):
189
+ return [array[:, :, j] for j in range(array.shape[2])]
190
+ else:
191
+ raise ValueError("Invalid axis. Use 0 for rows, 1 for columns, or 2 for depth.")
192
+
193
+
194
+ def plotly_visualize_pose(
195
+ poses, vis_depth=0.5, xyz_length=0.5, center_size=2, xyz_width=5, mesh_opacity=0.05
196
+ ):
197
+ """
198
+ Create comprehensive Plotly visualization traces for camera poses.
199
+ """
200
+ N = len(poses)
201
+
202
+ # Calculate camera centers in world coordinates
203
+ centers_cam = np.zeros([N, 1, 3]) # Camera centers in camera space (origin)
204
+ centers_world = cam2world(centers_cam, poses) # Transform to world space
205
+ centers_world = centers_world[:, 0] # Remove extra dimension [N, 3]
206
+
207
+ # Generate camera frustum geometry
208
+ vertices, faces, wireframe = get_camera_mesh(poses, depth=vis_depth)
209
+
210
+ # Merge all camera meshes into single arrays for efficient rendering
211
+ vertices_merged, faces_merged = merge_meshes(vertices, faces)
212
+ wireframe_merged = merge_wireframes_plotly(wireframe)
213
+
214
+ # Extract x, y, z coordinates for Plotly
215
+ wireframe_x, wireframe_y, wireframe_z = unbind_np(wireframe_merged, axis=-1)
216
+ centers_x, centers_y, centers_z = unbind_np(centers_world, axis=-1)
217
+ vertices_x, vertices_y, vertices_z = unbind_np(vertices_merged, axis=-1)
218
+
219
+ # Set up rainbow color mapping for trajectory progression
220
+ color_map = plt.get_cmap("gist_rainbow") # red -> yellow -> green -> blue -> purple
221
+ center_color = []
222
+ faces_merged_color = []
223
+ wireframe_color = []
224
+
225
+ # Determine quarter positions for emphasis (start, 1/3, 2/3, end)
226
+ quarter_indices = set([0]) # Always include start
227
+ if N >= 3:
228
+ quarter_indices.add(N // 3)
229
+ quarter_indices.add(2 * N // 3)
230
+ quarter_indices.add(N - 1) # Always include end
231
+
232
+ # Apply colors with emphasis on key trajectory points
233
+ for i in range(N):
234
+ # Emphasize quarter positions with higher opacity and brightness
235
+ is_quarter = i in quarter_indices
236
+ alpha = 6.0 if is_quarter else 0.4 # Higher opacity for key points
237
+
238
+ # Generate color from rainbow colormap
239
+ r, g, b, _ = color_map(i / (N - 1))
240
+ rgb = np.array([r, g, b]) * (1.2 if is_quarter else 0.8) # Brighten key points
241
+ rgba = np.concatenate([rgb, [alpha]])
242
+
243
+ # Apply colors to all visualization elements
244
+ wireframe_color += [rgba] * 11 # 11 line segments per camera wireframe
245
+ center_color += [rgba]
246
+ faces_merged_color += [rgba] * 6 # 6 triangular faces per camera frustum
247
+
248
+ # Create Plotly trace objects
249
+ plotly_traces = [
250
+ # Camera wireframe outlines
251
+ go.Scatter3d(
252
+ x=wireframe_x,
253
+ y=wireframe_y,
254
+ z=wireframe_z,
255
+ mode="lines",
256
+ line=dict(color=wireframe_color, width=1),
257
+ name="Camera Wireframes",
258
+ ),
259
+ # Camera center points
260
+ go.Scatter3d(
261
+ x=centers_x,
262
+ y=centers_y,
263
+ z=centers_z,
264
+ mode="markers",
265
+ marker=dict(color=center_color, size=center_size, opacity=1),
266
+ name="Camera Centers",
267
+ ),
268
+ # Camera frustum mesh faces
269
+ go.Mesh3d(
270
+ x=vertices_x,
271
+ y=vertices_y,
272
+ z=vertices_z,
273
+ i=[f[0] for f in faces_merged],
274
+ j=[f[1] for f in faces_merged],
275
+ k=[f[2] for f in faces_merged],
276
+ facecolor=faces_merged_color,
277
+ opacity=mesh_opacity,
278
+ name="Camera Frustums",
279
+ ),
280
+ ]
281
+ return plotly_traces
282
+
283
+
284
+ def compute_optimal_camera_view(poses):
285
+ """
286
+ Compute optimal camera view parameters to ensure the entire trajectory is visible
287
+ and aesthetically pleasing.
288
+ """
289
+ # Calculate all camera positions in world coordinates
290
+ centers_cam = np.zeros([len(poses), 1, 3])
291
+ centers_world = cam2world(centers_cam, poses)[:, 0]
292
+
293
+ # Compute bounding box of the trajectory
294
+ min_coords = np.min(centers_world, axis=0)
295
+ max_coords = np.max(centers_world, axis=0)
296
+ ranges = max_coords - min_coords
297
+
298
+ # Calculate trajectory center point
299
+ trajectory_center = (min_coords + max_coords) / 2
300
+
301
+ # Calculate maximum range for adaptive scaling
302
+ max_range = np.max(ranges)
303
+
304
+ # Set minimum range to avoid division by zero for very small trajectories
305
+ if max_range < 1e-6:
306
+ max_range = 1.0
307
+ ranges = np.ones(3)
308
+
309
+ # Calculate principal direction of trajectory using PCA (Principal Component Analysis)
310
+ if len(centers_world) > 1:
311
+ # Center the points by subtracting the mean
312
+ centered_points = centers_world - trajectory_center
313
+
314
+ # Compute covariance matrix for PCA
315
+ cov_matrix = np.cov(centered_points.T)
316
+
317
+ # Calculate eigenvalues and eigenvectors
318
+ eigenvalues, eigenvectors = np.linalg.eigh(cov_matrix)
319
+
320
+ # Sort by eigenvalues in descending order
321
+ idx = np.argsort(eigenvalues)[::-1]
322
+ eigenvalues = eigenvalues[idx]
323
+ eigenvectors = eigenvectors[:, idx]
324
+
325
+ # Main direction is the first eigenvector (highest variance)
326
+ main_direction = eigenvectors[:, 0]
327
+
328
+ # Ensure main direction points towards trajectory's positive direction
329
+ start_to_end = centers_world[-1] - centers_world[0]
330
+ if np.dot(main_direction, start_to_end) < 0:
331
+ main_direction = -main_direction
332
+
333
+ else:
334
+ # Default direction for single pose or insufficient data
335
+ main_direction = np.array([1, 0, 0])
336
+
337
+ # Calculate optimal camera distance
338
+ # Based on trajectory range and field of view, using smaller factor for better screen filling
339
+ fov_factor = (
340
+ 0.8 # Reduced field of view factor to make trajectory occupy more screen space
341
+ )
342
+ base_distance = max_range * fov_factor
343
+
344
+ # Consider trajectory aspect ratio and adjust distance accordingly
345
+ aspect_ratios = ranges / max_range
346
+ distance_scale = 1.0 + 0.1 * np.std(
347
+ aspect_ratios
348
+ ) # Reduced distance adjustment magnitude
349
+ camera_distance = base_distance * distance_scale
350
+
351
+ # Calculate optimal camera position
352
+ # Method 1: Diagonal viewing angle based on main direction
353
+ up_vector = np.array([0, 0, 1]) # World up direction (Z-axis)
354
+
355
+ # Adjust strategy if main direction is nearly vertical
356
+ if abs(np.dot(main_direction, up_vector)) > 0.9:
357
+ # Main direction is nearly vertical, use side view
358
+ view_direction = np.cross(main_direction, np.array([1, 0, 0]))
359
+ if np.linalg.norm(view_direction) < 0.1:
360
+ view_direction = np.cross(main_direction, np.array([0, 1, 0]))
361
+ view_direction = view_direction / np.linalg.norm(view_direction)
362
+ else:
363
+ # Calculate diagonal view direction perpendicular to main direction
364
+ # Combine horizontal component of main direction with tilt angle
365
+ horizontal_component = (
366
+ main_direction - np.dot(main_direction, up_vector) * up_vector
367
+ )
368
+ horizontal_component = horizontal_component / (
369
+ np.linalg.norm(horizontal_component) + 1e-8
370
+ )
371
+
372
+ # Add some tilt angles for better 3D perspective
373
+ elevation_angle = np.pi / 6 # 30 degrees elevation angle
374
+ azimuth_offset = np.pi / 4 # 45 degrees azimuth offset
375
+
376
+ # Create tilted view direction for optimal 3D perspective
377
+ view_direction = (
378
+ horizontal_component * np.cos(azimuth_offset) * np.cos(elevation_angle)
379
+ + np.cross(horizontal_component, up_vector)
380
+ * np.sin(azimuth_offset)
381
+ * np.cos(elevation_angle)
382
+ + up_vector * np.sin(elevation_angle)
383
+ )
384
+
385
+ # Calculate camera eye position
386
+ camera_eye = trajectory_center + view_direction * camera_distance
387
+
388
+ # Fine-tune camera position to ensure entire trajectory is within view
389
+ # Calculate vectors from camera position to all trajectory points
390
+ view_vectors = centers_world - camera_eye
391
+ view_distances = np.linalg.norm(view_vectors, axis=1)
392
+
393
+ # Adjust camera distance moderately if some points are too close
394
+ min_distance = camera_distance * 0.3 # Reduced minimum distance ratio
395
+ if np.min(view_distances) < min_distance:
396
+ distance_adjustment = min_distance / np.min(view_distances)
397
+ # Limit adjustment magnitude to avoid excessive scaling
398
+ distance_adjustment = min(
399
+ distance_adjustment, 1.2
400
+ ) # Further limit adjustment range
401
+ camera_eye = (
402
+ trajectory_center + view_direction * camera_distance * distance_adjustment
403
+ )
404
+
405
+ # Calculate adaptive parameters with appropriate proportions
406
+ auto_vis_depth = max_range * 0.08 # Moderately reduced camera frustum size
407
+ auto_center_size = max_range * 1.5 # Moderately reduced center point size
408
+
409
+ # Ensure parameters are within reasonable bounds
410
+ auto_vis_depth = max(0.01, min(auto_vis_depth, max_range * 0.2))
411
+ auto_center_size = max(0.1, min(auto_center_size, max_range * 2.0))
412
+
413
+ return {
414
+ "camera_eye": camera_eye,
415
+ "trajectory_center": trajectory_center,
416
+ "auto_vis_depth": auto_vis_depth,
417
+ "auto_center_size": auto_center_size,
418
+ "max_range": max_range,
419
+ "ranges": ranges,
420
+ "main_direction": main_direction,
421
+ }
422
+
423
+
424
+ def compute_multiple_camera_views(poses):
425
+ """
426
+ Compute multiple optimized camera view angles, providing different viewing options.
427
+ """
428
+ base_params = compute_optimal_camera_view(poses)
429
+
430
+ trajectory_center = base_params["trajectory_center"]
431
+ max_range = base_params["max_range"]
432
+ main_direction = base_params["main_direction"]
433
+
434
+ # Calculate multiple view options
435
+ views = {}
436
+
437
+ # 1. Best automatic view (original optimal view)
438
+ views["optimal"] = base_params
439
+
440
+ # 2. Top-down bird's eye view
441
+ top_distance = max_range * 1.5 # Further reduced top-down view distance
442
+ views["top"] = {
443
+ **base_params,
444
+ "camera_eye": trajectory_center + np.array([0, 0, top_distance]),
445
+ "description": "Top-down view",
446
+ }
447
+
448
+ # 3. Side view perspective
449
+ side_distance = max_range * 1.3 # Further reduced side view distance
450
+ side_direction = np.cross(main_direction, np.array([0, 0, 1]))
451
+ if np.linalg.norm(side_direction) < 0.1:
452
+ side_direction = np.array([1, 0, 0])
453
+ else:
454
+ side_direction = side_direction / np.linalg.norm(side_direction)
455
+
456
+ views["side"] = {
457
+ **base_params,
458
+ "camera_eye": trajectory_center + side_direction * side_distance,
459
+ "description": "Side view",
460
+ }
461
+
462
+ # 4. Diagonal view (45-degree elevation)
463
+ diagonal_distance = max_range * 1.4 # Further reduced diagonal view distance
464
+ elevation = np.pi / 4 # 45 degrees elevation
465
+ azimuth = np.pi / 4 # 45 degrees azimuth angle
466
+
467
+ diagonal_direction = np.array(
468
+ [
469
+ np.cos(elevation) * np.cos(azimuth),
470
+ np.cos(elevation) * np.sin(azimuth),
471
+ np.sin(elevation),
472
+ ]
473
+ )
474
+
475
+ views["diagonal"] = {
476
+ **base_params,
477
+ "camera_eye": trajectory_center + diagonal_direction * diagonal_distance,
478
+ "description": "Diagonal view (45° elevation)",
479
+ }
480
+
481
+ # 5. Trajectory start-oriented view
482
+ if len(poses) > 1:
483
+ start_to_center = trajectory_center - base_params["camera_eye"]
484
+ start_distance = max_range * 1.2 # Further reduced start view distance
485
+ start_direction = start_to_center / (np.linalg.norm(start_to_center) + 1e-8)
486
+
487
+ views["trajectory_start"] = {
488
+ **base_params,
489
+ "camera_eye": trajectory_center + start_direction * start_distance,
490
+ "description": "View from trajectory start direction",
491
+ }
492
+
493
+ # 6. Compact view - ensure entire trajectory is fully visible
494
+ fit_distance = max_range * 0.6 # Very compact distance for close-up view
495
+ fit_direction = np.array([0.7, 0.7, 0.5]) # Stable viewing direction
496
+ fit_direction = fit_direction / np.linalg.norm(fit_direction)
497
+
498
+ views["fit_all"] = {
499
+ **base_params,
500
+ "camera_eye": trajectory_center + fit_direction * fit_distance,
501
+ "description": "Fit all trajectory in view",
502
+ }
503
+
504
+ return views
505
+
506
+
507
+ def add_view_selector_to_html(html_str, views):
508
+ """
509
+ Add interactive view selector to HTML visualization.
510
+
511
+ This function injects JavaScript code into the HTML to provide an interactive
512
+ interface for switching between different camera views and enabling auto-rotation.
513
+
514
+ Args:
515
+ html_str: Original HTML string containing the Plotly visualization
516
+ views: Dictionary of view configurations
517
+
518
+ Returns:
519
+ str: Enhanced HTML string with view selector and controls
520
+ """
521
+
522
+ # Generate JavaScript code for view selector
523
+ view_selector_js = """
524
+ <div id="view-selector" style="position: fixed; top: 10px; left: 10px; background: rgba(255,255,255,0.9); padding: 15px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); font-family: Arial, sans-serif; font-size: 12px; z-index: 1000; min-width: 120px;">
525
+ <button onclick="autoRotate()" style="background: #ffc107; color: black; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; width: 100%;">Auto Rotate</button>
526
+ </div>
527
+
528
+ <script>
529
+ // Pre-defined view configurations
530
+ const views = {"""
531
+
532
+ # Add view data to JavaScript
533
+ for view_name, view_data in views.items():
534
+ eye = view_data["camera_eye"]
535
+ center = view_data["trajectory_center"]
536
+ view_selector_js += f"""
537
+ {view_name}: {{
538
+ eye: {{x: {eye[0]:.6f}, y: {eye[1]:.6f}, z: {eye[2]:.6f}}},
539
+ center: {{x: {center[0]:.6f}, y: {center[1]:.6f}, z: {center[2]:.6f}}},
540
+ up: {{x: 0, y: 0, z: 1}}
541
+ }},"""
542
+
543
+ view_selector_js += """
544
+ };
545
+
546
+ let rotationInterval = null;
547
+
548
+ function autoRotate() {
549
+ if (rotationInterval) {
550
+ clearInterval(rotationInterval);
551
+ rotationInterval = null;
552
+ return;
553
+ }
554
+
555
+ var plotlyDiv = document.querySelector('.plotly-graph-div');
556
+ if (!plotlyDiv) return;
557
+
558
+ var currentView = views.fit_all;
559
+ var center = currentView.center;
560
+ var radius = Math.sqrt(
561
+ Math.pow(currentView.eye.x - center.x, 2) +
562
+ Math.pow(currentView.eye.y - center.y, 2) +
563
+ Math.pow(currentView.eye.z - center.z, 2)
564
+ );
565
+
566
+ var angle = 0;
567
+ rotationInterval = setInterval(function() {
568
+ angle += 0.02; // Rotation speed
569
+
570
+ var newEye = {
571
+ x: center.x + radius * Math.cos(angle) * 0.7,
572
+ y: center.y + radius * Math.sin(angle) * 0.7,
573
+ z: center.z + radius * 0.5
574
+ };
575
+
576
+ var update = {
577
+ 'scene.camera.eye': newEye
578
+ };
579
+
580
+ Plotly.relayout(plotlyDiv, update);
581
+ }, 50);
582
+ }
583
+
584
+ // Set default view after page loading is complete
585
+ document.addEventListener('DOMContentLoaded', function() {
586
+ setTimeout(function() {
587
+ // Use Fit All as default view, no button operation required
588
+ var plotlyDiv = document.querySelector('.plotly-graph-div');
589
+ if (plotlyDiv && views.fit_all) {
590
+ var update = {
591
+ 'scene.camera': views.fit_all
592
+ };
593
+ Plotly.relayout(plotlyDiv, update);
594
+ }
595
+ }, 1000);
596
+ });
597
+ </script>
598
+ """
599
+
600
+ # Add view selector to the beginning of HTML
601
+ return view_selector_js + html_str
602
+
603
+
604
+ def write_html(poses, file, vis_depth=1, xyz_length=0.2, center_size=0.01, xyz_width=2):
605
+ """
606
+ Write camera pose visualization to HTML file with optimized camera view.
607
+ """
608
+ # Calculate basic optimal view parameters
609
+ base_view = compute_optimal_camera_view(poses)
610
+
611
+ # Extract trajectory information
612
+ trajectory_center = base_view["trajectory_center"]
613
+ max_range = base_view["max_range"]
614
+ ranges = base_view["ranges"]
615
+ auto_vis_depth = base_view["auto_vis_depth"]
616
+ auto_center_size = base_view["auto_center_size"]
617
+
618
+ # Calculate optimal view to see entire trajectory
619
+ # Use larger distance to ensure entire trajectory is visible with better angles
620
+ optimal_distance = (
621
+ max_range * 1.8 * 10
622
+ ) # Increase distance by 10x for better overall view
623
+
624
+ # Choose ideal angle that can see the full trajectory
625
+ # Use combination of 45-degree elevation and azimuth for good 3D perspective
626
+ elevation = np.pi / 4 # 45-degree elevation angle
627
+ azimuth = np.pi / 4 # 45-degree azimuth angle
628
+
629
+ # Calculate optimal viewing direction
630
+ optimal_direction = np.array(
631
+ [
632
+ np.cos(elevation) * np.cos(azimuth),
633
+ np.cos(elevation) * np.sin(azimuth),
634
+ np.sin(elevation),
635
+ ]
636
+ )
637
+
638
+ # Calculate optimal camera position
639
+ camera_eye = trajectory_center + optimal_direction * optimal_distance
640
+
641
+ # Verify view coverage - ensure all trajectory points are within reasonable distance
642
+ centers_cam = np.zeros([len(poses), 1, 3])
643
+ centers_world = cam2world(centers_cam, poses)[:, 0]
644
+
645
+ # Calculate distances from optimal camera position to all trajectory points
646
+ distances_to_points = np.linalg.norm(centers_world - camera_eye, axis=1)
647
+ max_distance_to_point = np.max(distances_to_points)
648
+ min_distance_to_point = np.min(distances_to_points)
649
+
650
+ # If distance variation is too large, the view might not be ideal, adjust accordingly
651
+ if max_distance_to_point / min_distance_to_point > 3.0:
652
+ # Recalculate more balanced distance
653
+ optimal_distance = max_range * 2.2 * 10 # Further increase distance (10x)
654
+ camera_eye = trajectory_center + optimal_direction * optimal_distance
655
+
656
+ # Create view dictionary with only optimal view for Auto Rotate
657
+ views = {
658
+ "fit_all": {
659
+ "camera_eye": camera_eye,
660
+ "trajectory_center": trajectory_center,
661
+ "auto_vis_depth": auto_vis_depth,
662
+ "auto_center_size": auto_center_size,
663
+ "max_range": max_range,
664
+ "ranges": ranges,
665
+ "description": "Optimal view to see entire trajectory",
666
+ }
667
+ }
668
+
669
+ print(f"Trajectory ranges: x={ranges[0]:.3f}, y={ranges[1]:.3f}, z={ranges[2]:.3f}")
670
+ print(f"Max range: {max_range:.3f}")
671
+ print(f"Auto vis_depth: {auto_vis_depth:.3f}, center_size: {auto_center_size:.3f}")
672
+ print(
673
+ f"Trajectory center: ({trajectory_center[0]:.3f}, {trajectory_center[1]:.3f}, {trajectory_center[2]:.3f})"
674
+ )
675
+ print(
676
+ f"Optimal camera position for full trajectory view: ({camera_eye[0]:.3f}, {camera_eye[1]:.3f}, {camera_eye[2]:.3f})"
677
+ )
678
+ print(f"Camera distance from trajectory center: {optimal_distance:.3f}")
679
+ print(
680
+ f"Distance range to trajectory points: {min_distance_to_point:.3f} - {max_distance_to_point:.3f}"
681
+ )
682
+
683
+ xyz_length = xyz_length / 3
684
+ xyz_width = xyz_width
685
+ vis_depth = auto_vis_depth # Use automatically computed depth
686
+ center_size = auto_center_size # Use automatically computed size
687
+
688
+ traces_poses = plotly_visualize_pose(
689
+ poses,
690
+ vis_depth=vis_depth,
691
+ xyz_length=xyz_length,
692
+ center_size=center_size,
693
+ xyz_width=xyz_width,
694
+ mesh_opacity=0.05,
695
+ )
696
+ traces_all2 = traces_poses
697
+ layout2 = go.Layout(
698
+ scene=dict(
699
+ xaxis=dict(visible=False),
700
+ yaxis=dict(visible=False),
701
+ zaxis=dict(visible=False),
702
+ dragmode="orbit",
703
+ aspectratio=dict(x=1, y=1, z=1),
704
+ aspectmode="data",
705
+ # Set initial camera view to fully see the trajectory with optimized positioning
706
+ camera=dict(
707
+ eye=dict(x=camera_eye[0], y=camera_eye[1], z=camera_eye[2]),
708
+ center=dict(
709
+ x=trajectory_center[0],
710
+ y=trajectory_center[1],
711
+ z=trajectory_center[2],
712
+ ),
713
+ up=dict(x=0, y=0, z=1),
714
+ ),
715
+ ),
716
+ height=800,
717
+ width=1200,
718
+ showlegend=False,
719
+ )
720
+
721
+ fig2 = go.Figure(data=traces_all2, layout=layout2)
722
+ html_str2 = pio.to_html(fig2, full_html=False)
723
+
724
+ # Add real-time camera view display functionality
725
+ camera_info_html = """
726
+ <div id="camera-info" style="position: fixed; top: 10px; right: 10px; background: rgba(255,255,255,0.9); padding: 15px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); font-family: monospace; font-size: 12px; z-index: 1000; min-width: 250px;">
727
+ <h4 style="margin: 0 0 10px 0; color: #333;">Camera Info</h4>
728
+ <div><strong>Eye:</strong></div>
729
+ <div>x: <span id="eye-x">2.000</span></div>
730
+ <div>y: <span id="eye-y">2.000</span></div>
731
+ <div>z: <span id="eye-z">1.000</span></div>
732
+ <br>
733
+ <div><strong>Center:</strong></div>
734
+ <div>x: <span id="center-x">0.000</span></div>
735
+ <div>y: <span id="center-y">0.000</span></div>
736
+ <div>z: <span id="center-z">0.000</span></div>
737
+ <br>
738
+ <div><strong>Up:</strong></div>
739
+ <div>x: <span id="up-x">0.000</span></div>
740
+ <div>y: <span id="up-y">0.000</span></div>
741
+ <div>z: <span id="up-z">1.000</span></div>
742
+ <br>
743
+ <button onclick="copyToClipboard()" style="background: #007bff; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; width: 100%;">Copy to Clipboard</button>
744
+ </div>
745
+
746
+ <script>
747
+ function updateCameraInfo() {
748
+ // Get Plotly chart
749
+ var plotlyDiv = document.querySelector('.plotly-graph-div');
750
+ if (!plotlyDiv) return;
751
+
752
+ // Listen for camera change events
753
+ plotlyDiv.on('plotly_relayout', function(eventData) {
754
+ if (eventData['scene.camera']) {
755
+ var camera = eventData['scene.camera'];
756
+ updateCameraDisplay(camera);
757
+ }
758
+ });
759
+
760
+ // Initial display
761
+ setTimeout(function() {
762
+ var gd = plotlyDiv;
763
+ if (gd.layout && gd.layout.scene && gd.layout.scene.camera) {
764
+ updateCameraDisplay(gd.layout.scene.camera);
765
+ }
766
+ }, 1000);
767
+ }
768
+
769
+ function updateCameraDisplay(camera) {
770
+ if (camera.eye) {
771
+ document.getElementById('eye-x').textContent = camera.eye.x.toFixed(3);
772
+ document.getElementById('eye-y').textContent = camera.eye.y.toFixed(3);
773
+ document.getElementById('eye-z').textContent = camera.eye.z.toFixed(3);
774
+ }
775
+ if (camera.center) {
776
+ document.getElementById('center-x').textContent = camera.center.x.toFixed(3);
777
+ document.getElementById('center-y').textContent = camera.center.y.toFixed(3);
778
+ document.getElementById('center-z').textContent = camera.center.z.toFixed(3);
779
+ }
780
+ if (camera.up) {
781
+ document.getElementById('up-x').textContent = camera.up.x.toFixed(3);
782
+ document.getElementById('up-y').textContent = camera.up.y.toFixed(3);
783
+ document.getElementById('up-z').textContent = camera.up.z.toFixed(3);
784
+ }
785
+ }
786
+
787
+ function copyToClipboard() {
788
+ var eyeX = document.getElementById('eye-x').textContent;
789
+ var eyeY = document.getElementById('eye-y').textContent;
790
+ var eyeZ = document.getElementById('eye-z').textContent;
791
+ var centerX = document.getElementById('center-x').textContent;
792
+ var centerY = document.getElementById('center-y').textContent;
793
+ var centerZ = document.getElementById('center-z').textContent;
794
+ var upX = document.getElementById('up-x').textContent;
795
+ var upY = document.getElementById('up-y').textContent;
796
+ var upZ = document.getElementById('up-z').textContent;
797
+
798
+ var cameraConfig = `camera=dict(
799
+ eye=dict(x=${eyeX}, y=${eyeY}, z=${eyeZ}),
800
+ center=dict(x=${centerX}, y=${centerY}, z=${centerZ}),
801
+ up=dict(x=${upX}, y=${upY}, z=${upZ})
802
+ )`;
803
+
804
+ navigator.clipboard.writeText(cameraConfig).then(function() {
805
+ alert('Copy to clipboard successful!');
806
+ }).catch(function(err) {
807
+ console.error('Copy failed:', err);
808
+ // Fallback: Create a temporary textarea
809
+ var textArea = document.createElement('textarea');
810
+ textArea.value = cameraConfig;
811
+ document.body.appendChild(textArea);
812
+ textArea.select();
813
+ document.execCommand('copy');
814
+ document.body.removeChild(textArea);
815
+ alert('Copy to clipboard successful!');
816
+ });
817
+ }
818
+
819
+ // Initialize camera info display
820
+ document.addEventListener('DOMContentLoaded', function() {
821
+ updateCameraInfo();
822
+ });
823
+
824
+ // If the page has already loaded
825
+ if (document.readyState === 'complete') {
826
+ updateCameraInfo();
827
+ }
828
+ </script>
829
+ """
830
+
831
+ # Add view selector and camera info to HTML
832
+ enhanced_html = add_view_selector_to_html(camera_info_html + html_str2, views)
833
+
834
+ file.write(enhanced_html)
835
+
836
+ print(f"Enhanced visualized poses are saved to {file.name}")
837
+ # Removed redundant view options printing
838
+
839
+
840
+ def plotly_visualize_pose_animated(
841
+ poses_full,
842
+ vis_depth=0.5,
843
+ xyz_length=0.5,
844
+ center_size=2,
845
+ xyz_width=5,
846
+ mesh_opacity=0.05,
847
+ ):
848
+ """
849
+ Create plotly visualization traces for camera poses, frame by frame for animation.
850
+ Now shows the full trajectory with future poses as completely transparent.
851
+ """
852
+ N_total = len(poses_full)
853
+ plotly_frames = []
854
+
855
+ # Pre-compute data for all poses to ensure consistent layout
856
+ centers_cam = np.zeros([N_total, 1, 3])
857
+ centers_world = cam2world(centers_cam, poses_full)
858
+ centers_world = centers_world[:, 0]
859
+ # Get the camera wireframes for all poses
860
+ vertices, faces, wireframe = get_camera_mesh(poses_full, depth=vis_depth)
861
+ vertices_merged, faces_merged = merge_meshes(vertices, faces)
862
+ wireframe_merged = merge_wireframes_plotly(wireframe)
863
+ # Break up (x,y,z) coordinates.
864
+ wireframe_x, wireframe_y, wireframe_z = unbind_np(wireframe_merged, axis=-1)
865
+ centers_x, centers_y, centers_z = unbind_np(centers_world, axis=-1)
866
+ vertices_x, vertices_y, vertices_z = unbind_np(vertices_merged, axis=-1)
867
+
868
+ # Initial frame showing all poses with appropriate transparency
869
+ initial_data = []
870
+
871
+ for i in tqdm(range(1, N_total + 1), desc="Generating animation frames"):
872
+ current_frame = i - 1 # Current frame index (0-based)
873
+
874
+ # Set the color map for the camera trajectory
875
+ color_map = plt.get_cmap("gist_rainbow")
876
+ center_color = []
877
+ faces_merged_color = []
878
+ wireframe_color = []
879
+
880
+ for k in range(N_total): # Process all poses
881
+ # Set the camera pose colors (with a smooth gradient color map).
882
+ r, g, b, _ = color_map(k / (N_total - 1))
883
+ rgb = np.array([r, g, b]) * 0.8
884
+
885
+ # Set transparency based on current frame
886
+ if k < current_frame: # Past poses - visible with reduced opacity
887
+ # Set transparency based on temporal distance, more distant = more transparent
888
+ time_distance = (current_frame - k) / max(current_frame, 1)
889
+ alpha = 0.15 + 0.25 * (1 - time_distance) # Transparency range 0.15-0.4
890
+ wireframe_alpha = alpha
891
+ mesh_alpha = alpha * 0.4
892
+ elif k == current_frame: # Current pose - fully visible
893
+ alpha = 0.8 # Fully opaque, dark display
894
+ wireframe_alpha = 0.8
895
+ mesh_alpha = 0.6
896
+ else: # Future poses - completely transparent
897
+ alpha = 0.0 # Completely transparent
898
+ wireframe_alpha = 0.0
899
+ mesh_alpha = 0.0
900
+
901
+ # Set colors and transparency
902
+ wireframe_color += [np.concatenate([rgb, [wireframe_alpha]])] * 11
903
+ center_color += [np.concatenate([rgb, [alpha]])]
904
+ faces_merged_color += [np.concatenate([rgb, [mesh_alpha]])] * 6
905
+
906
+ frame_data = [
907
+ go.Scatter3d(
908
+ x=wireframe_x,
909
+ y=wireframe_y,
910
+ z=wireframe_z,
911
+ mode="lines",
912
+ line=dict(color=wireframe_color, width=1),
913
+ ),
914
+ go.Scatter3d(
915
+ x=centers_x,
916
+ y=centers_y,
917
+ z=centers_z,
918
+ mode="markers",
919
+ marker=dict(color=center_color, size=center_size),
920
+ ),
921
+ go.Mesh3d(
922
+ x=vertices_x,
923
+ y=vertices_y,
924
+ z=vertices_z,
925
+ i=[f[0] for f in faces_merged],
926
+ j=[f[1] for f in faces_merged],
927
+ k=[f[2] for f in faces_merged],
928
+ facecolor=faces_merged_color,
929
+ opacity=0.6, # Set base opacity for mesh
930
+ ),
931
+ ]
932
+
933
+ if i == 1: # Set initial data for the first frame
934
+ initial_data = frame_data
935
+
936
+ plotly_frames.append(go.Frame(data=frame_data, name=str(i)))
937
+
938
+ return initial_data, plotly_frames
939
+
940
+
941
+ def write_html_animated(
942
+ poses, file, vis_depth=1, xyz_length=0.2, center_size=0.01, xyz_width=2
943
+ ):
944
+ """
945
+ Write camera pose visualization with animation to HTML file with optimized camera view.
946
+ """
947
+ # Calculate basic optimal view parameters
948
+ base_view = compute_optimal_camera_view(poses)
949
+
950
+ # Extract trajectory information
951
+ trajectory_center = base_view["trajectory_center"]
952
+ max_range = base_view["max_range"]
953
+ ranges = base_view["ranges"]
954
+ auto_vis_depth = base_view["auto_vis_depth"]
955
+ auto_center_size = base_view["auto_center_size"]
956
+
957
+ # Calculate optimal view to see entire trajectory
958
+ # Use larger distance to ensure entire trajectory is visible with better angles
959
+ optimal_distance = (
960
+ max_range * 1.8 * 10
961
+ ) # Increase distance by 10x for better overall view
962
+
963
+ # Choose ideal angle that can see the full trajectory
964
+ # Use combination of 45-degree elevation and azimuth for good 3D perspective
965
+ elevation = np.pi / 4 # 45-degree elevation angle
966
+ azimuth = np.pi / 4 # 45-degree azimuth angle
967
+
968
+ # Calculate optimal viewing direction
969
+ optimal_direction = np.array(
970
+ [
971
+ np.cos(elevation) * np.cos(azimuth),
972
+ np.cos(elevation) * np.sin(azimuth),
973
+ np.sin(elevation),
974
+ ]
975
+ )
976
+
977
+ # Calculate optimal camera position
978
+ camera_eye = trajectory_center + optimal_direction * optimal_distance
979
+
980
+ # Verify view coverage - ensure all trajectory points are within reasonable distance
981
+ centers_cam = np.zeros([len(poses), 1, 3])
982
+ centers_world = cam2world(centers_cam, poses)[:, 0]
983
+
984
+ # Calculate distances from optimal camera position to all trajectory points
985
+ distances_to_points = np.linalg.norm(centers_world - camera_eye, axis=1)
986
+ max_distance_to_point = np.max(distances_to_points)
987
+ min_distance_to_point = np.min(distances_to_points)
988
+
989
+ # If distance variation is too large, the view might not be ideal, adjust accordingly
990
+ if max_distance_to_point / min_distance_to_point > 3.0:
991
+ # Recalculate more balanced distance
992
+ optimal_distance = max_range * 2.2 * 10 # Further increase distance (10x)
993
+ camera_eye = trajectory_center + optimal_direction * optimal_distance
994
+
995
+ # Adjust parameters for animation
996
+ xyz_length = xyz_length / 3
997
+ xyz_width = xyz_width
998
+ vis_depth = auto_vis_depth # Use automatically computed depth
999
+ center_size = auto_center_size # Use automatically computed size
1000
+
1001
+ print(
1002
+ f"Animation - Trajectory ranges: x={ranges[0]:.3f}, y={ranges[1]:.3f}, z={ranges[2]:.3f}"
1003
+ )
1004
+ print(f"Animation - Max range: {max_range:.3f}")
1005
+ print(
1006
+ f"Animation - Auto vis_depth: {auto_vis_depth:.3f}, center_size: {auto_center_size:.3f}"
1007
+ )
1008
+ print(
1009
+ f"Animation - Trajectory center: ({trajectory_center[0]:.3f}, {trajectory_center[1]:.3f}, {trajectory_center[2]:.3f})"
1010
+ )
1011
+ print(
1012
+ f"Animation - Optimal camera position for full trajectory view: ({camera_eye[0]:.3f}, {camera_eye[1]:.3f}, {camera_eye[2]:.3f})"
1013
+ )
1014
+ print(f"Animation - Camera distance from trajectory center: {optimal_distance:.3f}")
1015
+ print(
1016
+ f"Animation - Distance range to trajectory points: {min_distance_to_point:.3f} - {max_distance_to_point:.3f}"
1017
+ )
1018
+
1019
+ initial_data, plotly_frames = plotly_visualize_pose_animated(
1020
+ poses,
1021
+ vis_depth=vis_depth,
1022
+ xyz_length=xyz_length,
1023
+ center_size=center_size,
1024
+ xyz_width=xyz_width,
1025
+ mesh_opacity=0.05,
1026
+ )
1027
+
1028
+ layout = go.Layout(
1029
+ scene=dict(
1030
+ xaxis=dict(visible=False),
1031
+ yaxis=dict(visible=False),
1032
+ zaxis=dict(visible=False),
1033
+ dragmode="orbit",
1034
+ aspectratio=dict(x=1, y=1, z=1),
1035
+ aspectmode="data",
1036
+ # Use optimized camera view settings (same 10x distance as write_html)
1037
+ camera=dict(
1038
+ eye=dict(x=camera_eye[0], y=camera_eye[1], z=camera_eye[2]),
1039
+ center=dict(
1040
+ x=trajectory_center[0],
1041
+ y=trajectory_center[1],
1042
+ z=trajectory_center[2],
1043
+ ),
1044
+ up=dict(x=0, y=0, z=1),
1045
+ ),
1046
+ ),
1047
+ height=800, # Increased height for better animation display
1048
+ width=1200, # Increased width for better animation display
1049
+ showlegend=False,
1050
+ updatemenus=[
1051
+ dict(
1052
+ type="buttons",
1053
+ buttons=[
1054
+ dict(
1055
+ label="Play",
1056
+ method="animate",
1057
+ args=[
1058
+ None,
1059
+ {
1060
+ "frame": {"duration": 50, "redraw": True},
1061
+ "fromcurrent": True,
1062
+ "transition": {"duration": 0},
1063
+ },
1064
+ ],
1065
+ )
1066
+ ],
1067
+ )
1068
+ ],
1069
+ )
1070
+
1071
+ fig = go.Figure(data=initial_data, layout=layout, frames=plotly_frames)
1072
+ html_str = pio.to_html(fig, full_html=False)
1073
+ file.write(html_str)
1074
+
1075
+ print(f"Visualized poses are saved to {file}")
1076
+
1077
+
1078
+ def quaternion_to_matrix(quaternions, eps: float = 1e-8):
1079
+ """
1080
+ Convert 4-dimensional quaternions to 3x3 rotation matrices.
1081
+
1082
+ Reference:
1083
+ https://github.com/facebookresearch/pytorch3d/blob/main/pytorch3d/transforms/rotation_conversions.py
1084
+ """
1085
+
1086
+ # Order changed to match scipy format: (i, j, k, r)
1087
+ i, j, k, r = torch.unbind(quaternions, dim=-1)
1088
+ two_s = 2 / ((quaternions * quaternions).sum(dim=-1) + eps)
1089
+
1090
+ # Construct rotation matrix elements using quaternion algebra
1091
+ o = torch.stack(
1092
+ (
1093
+ 1 - two_s * (j * j + k * k), # R[0,0]
1094
+ two_s * (i * j - k * r), # R[0,1]
1095
+ two_s * (i * k + j * r), # R[0,2]
1096
+ two_s * (i * j + k * r), # R[1,0]
1097
+ 1 - two_s * (i * i + k * k), # R[1,1]
1098
+ two_s * (j * k - i * r), # R[1,2]
1099
+ two_s * (i * k - j * r), # R[2,0]
1100
+ two_s * (j * k + i * r), # R[2,1]
1101
+ 1 - two_s * (i * i + j * j), # R[2,2]
1102
+ ),
1103
+ -1,
1104
+ )
1105
+ return einops.rearrange(o, "... (i j) -> ... i j", i=3, j=3)
1106
+
1107
+
1108
+ def pose_from_quaternion(pose):
1109
+ """
1110
+ Convert quaternion-based pose representation to 4x4 transformation matrices.
1111
+
1112
+ Reference:
1113
+ https://github.com/pointrix-project/Geomotion/blob/6ab0c364f1b44ab4ea190085dbf068f62b42727c/geomotion/model/cameras.py#L6
1114
+ """
1115
+ # Convert numpy array to torch tensor if needed
1116
+ if type(pose) == np.ndarray:
1117
+ pose = torch.tensor(pose)
1118
+ # Add batch dimension if input is 1D
1119
+ if len(pose.shape) == 1:
1120
+ pose = pose[None]
1121
+ # Extract translation and quaternion components
1122
+ quat_t = pose[..., :3] # Translation components [tx, ty, tz]
1123
+ quat_r = pose[..., 3:] # Quaternion components [qi, qj, qk, qr]
1124
+
1125
+ # Initialize world-to-camera transformation matrix
1126
+ w2c_matrix = torch.zeros((*list(pose.shape)[:-1], 3, 4), device=pose.device)
1127
+ w2c_matrix[..., :3, 3] = quat_t # Set translation part
1128
+ w2c_matrix[..., :3, :3] = quaternion_to_matrix(quat_r) # Set rotation part
1129
+ return w2c_matrix
1130
+
1131
+
1132
+ def viz_poses(i, pth, file, scale_factor, dynamic, vis_depth):
1133
+ """
1134
+ Visualize camera poses for a sequence and write to HTML file.
1135
+ """
1136
+ file.write(f"<span style='font-size: 18pt;'>{i} {pth}</span><br>")
1137
+
1138
+ # Load pose data from file
1139
+ pose = np.load(pth)
1140
+
1141
+ # Convert quaternion poses to transformation matrices
1142
+ # poses = pose_from_quaternion(pose) # Input: (N,7), Output: (N,3,4) w2c matrices
1143
+ # poses = poses.cpu().numpy()
1144
+ if isinstance(pose, np.ndarray):
1145
+ if pose.shape[1] == 3:
1146
+ c2w = np.eye(4)
1147
+ c2w = repeat(c2w, "i j -> n i j", n=pose.shape[0])
1148
+ c2w[:, :3] = pose
1149
+ pose = c2w
1150
+ poses = np.linalg.inv(pose)[:, :3]
1151
+ else:
1152
+ poses = np.linalg.inv(pose["data"])[:, :3]
1153
+
1154
+ # Apply scaling to translation part (camera positions) while keeping rotation unchanged
1155
+ # Create scaled copy of poses
1156
+ poses_scaled = poses.copy()
1157
+ poses_scaled[..., :3, 3] = poses[..., :3, 3] * scale_factor
1158
+
1159
+ print(f"Original poses shape: {poses.shape}")
1160
+ print(f"Applied scale factor: {scale_factor}")
1161
+
1162
+ # Generate visualization based on dynamic flag
1163
+ if dynamic:
1164
+ write_html_animated(poses_scaled, file, vis_depth=vis_depth)
1165
+ else:
1166
+ write_html(poses_scaled, file, vis_depth=vis_depth)
1167
+
1168
+
1169
+ def vis_to_html(outdir, datas, scale_factor=0.3, dynamic=False, vis_depth=0.2):
1170
+ # Create output directory and process pose files
1171
+ os.makedirs(outdir, exist_ok=True)
1172
+
1173
+ with open(f"{outdir}/visualize.html", "w") as file:
1174
+ for i, pth in enumerate(tqdm(datas, desc="Processing pose files")):
1175
+ if not os.path.exists(pth):
1176
+ print(f"Warning: Path {pth} does not exist, skipping.")
1177
+ continue
1178
+ print(f"Processing: {pth} (#{i+1})")
1179
+ viz_poses(i, pth, file, scale_factor, dynamic, vis_depth)
1180
+
1181
+
1182
+ if __name__ == "__main__":
1183
+ # Set up command-line argument parser
1184
+ parser = argparse.ArgumentParser(
1185
+ description="Visualize camera poses with interactive 3D plots",
1186
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1187
+ )
1188
+
1189
+ parser.add_argument(
1190
+ "--datas",
1191
+ type=str,
1192
+ nargs="+",
1193
+ required=True,
1194
+ help="List of pose file paths (.npz format) to visualize.",
1195
+ )
1196
+ parser.add_argument(
1197
+ "--vis_depth",
1198
+ type=float,
1199
+ default=0.2,
1200
+ help="Depth of camera frustum visualization (default: 0.2).",
1201
+ )
1202
+ parser.add_argument(
1203
+ "--scale_factor",
1204
+ type=float,
1205
+ default=0.3,
1206
+ help="Scale factor to reduce distance between cameras - smaller values bring cameras closer together (default: 0.3).",
1207
+ )
1208
+ parser.add_argument(
1209
+ "--outdir",
1210
+ type=str,
1211
+ default="./visualize",
1212
+ help="Output directory to save HTML visualization files (default: ./visualize).",
1213
+ )
1214
+ parser.add_argument(
1215
+ "--dynamic",
1216
+ action="store_true",
1217
+ help="Create animated visualization showing camera trajectory progression over time.",
1218
+ )
1219
+
1220
+ # Parse command-line arguments
1221
+ args = parser.parse_args()
1222
+
1223
+ print(f"Processing {len(args.datas)} pose file(s)...")
1224
+ print(f"Output directory: {args.outdir}")
1225
+ print(f"Visualization type: {'Animated' if args.dynamic else 'Static'}")
1226
+
1227
+ vis_to_html(args.outdir, args.datas, args.scale_factor, args.dynamic, args.vis_depth)
1228
+
1229
+ print(
1230
+ f"Visualization complete! Open {args.outdir}/visualize.html in your browser to view results."
1231
+ )
UCPE/tools/visualize_re10k.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import argparse
3
+ import numpy as np
4
+ import matplotlib as mpl
5
+ import matplotlib.pyplot as plt
6
+ from matplotlib.patches import Patch
7
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
8
+ import os
9
+ from tqdm.auto import tqdm
10
+ import imageio
11
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
12
+
13
+
14
+ class CameraPoseVisualizer:
15
+ def __init__(self, xlim, ylim, zlim):
16
+ self.fig = plt.figure(figsize=(7, 7), dpi=300)
17
+ self.ax = self.fig.add_subplot(projection='3d')
18
+ self.plotly_data = None # plotly data traces
19
+ self.xlim = xlim
20
+ self.ylim = ylim
21
+ self.zlim = zlim
22
+ self.init_ax()
23
+ print('initialize camera pose visualizer')
24
+
25
+ def init_ax(self):
26
+ self.ax.cla()
27
+ self.ax.set_aspect("auto")
28
+ self.ax.set_xlim(self.xlim)
29
+ self.ax.set_ylim(self.ylim)
30
+ self.ax.set_zlim(self.zlim)
31
+ self.ax.set_xlabel('x')
32
+ self.ax.set_ylabel('y')
33
+ self.ax.set_zlabel('z')
34
+
35
+ def extrinsic2pyramid(self, extrinsic, color_map='red', hw_ratio=9/16, base_xval=1, zval=3):
36
+ vertex_std = np.array([[0, 0, 0, 1],
37
+ [base_xval, -base_xval * hw_ratio, zval, 1],
38
+ [base_xval, base_xval * hw_ratio, zval, 1],
39
+ [-base_xval, base_xval * hw_ratio, zval, 1],
40
+ [-base_xval, -base_xval * hw_ratio, zval, 1]])
41
+ vertex_transformed = vertex_std @ extrinsic.T
42
+ meshes = [[vertex_transformed[0, :-1], vertex_transformed[1][:-1], vertex_transformed[2, :-1]],
43
+ [vertex_transformed[0, :-1], vertex_transformed[2, :-1], vertex_transformed[3, :-1]],
44
+ [vertex_transformed[0, :-1], vertex_transformed[3, :-1], vertex_transformed[4, :-1]],
45
+ [vertex_transformed[0, :-1], vertex_transformed[4, :-1], vertex_transformed[1, :-1]],
46
+ [vertex_transformed[1, :-1], vertex_transformed[2, :-1], vertex_transformed[3, :-1], vertex_transformed[4, :-1]]]
47
+
48
+ color = color_map if isinstance(color_map, str) else plt.cm.rainbow(color_map)
49
+
50
+ self.ax.add_collection3d(
51
+ Poly3DCollection(meshes, facecolors=color, linewidths=0.3, edgecolors=color, alpha=0.35))
52
+
53
+ def customize_legend(self, list_label):
54
+ list_handle = []
55
+ for idx, label in enumerate(list_label):
56
+ color = plt.cm.rainbow(idx / len(list_label))
57
+ patch = Patch(color=color, label=label)
58
+ list_handle.append(patch)
59
+ plt.legend(loc='right', bbox_to_anchor=(1.8, 0.5), handles=list_handle)
60
+
61
+ def colorbar(self, max_frame_length):
62
+ cmap = mpl.cm.rainbow
63
+ norm = mpl.colors.Normalize(vmin=0, vmax=max_frame_length)
64
+ self.fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=self.ax, orientation='vertical', label='Frame Number')
65
+
66
+ def show(self, out_file_path):
67
+ plt.title('Extrinsic Parameters')
68
+ os.makedirs('debug/visualize_re10k', exist_ok=True)
69
+ plt.savefig(out_file_path, format='png', dpi=600)
70
+ plt.show()
71
+
72
+ def draw(self):
73
+ canvas = FigureCanvasAgg(self.fig)
74
+ canvas.draw()
75
+ buf = canvas.buffer_rgba()
76
+ img = np.asarray(buf, dtype=np.uint8)
77
+ return img[:, :, :3]
78
+
79
+ def vis_pose(self, c2ws, out_file_path, hw_ratio, base_xval, zval):
80
+ num_frames = len(c2ws)
81
+ self.colorbar(num_frames)
82
+ self.init_ax()
83
+
84
+ for frame_idx, c2w in enumerate(c2ws):
85
+ self.extrinsic2pyramid(
86
+ c2w,
87
+ frame_idx / num_frames,
88
+ hw_ratio=hw_ratio,
89
+ base_xval=base_xval,
90
+ zval=zval
91
+ )
92
+
93
+ self.show(out_file_path)
94
+
95
+ def anim_pose(self, c2ws, out_file_path, hw_ratio, base_xval, zval, fps, keyframe_interval):
96
+ num_frames = len(c2ws)
97
+ self.colorbar(num_frames)
98
+ writer = imageio.get_writer(out_file_path, fps=fps)
99
+
100
+ keyframes = []
101
+ for frame_idx, c2w in enumerate(c2ws):
102
+ self.init_ax()
103
+ if keyframes:
104
+ for kf in keyframes:
105
+ self.extrinsic2pyramid(
106
+ c2ws[kf],
107
+ kf / num_frames,
108
+ hw_ratio=hw_ratio,
109
+ base_xval=base_xval,
110
+ zval=zval
111
+ )
112
+ if frame_idx % keyframe_interval == 0:
113
+ keyframes.append(frame_idx)
114
+ self.extrinsic2pyramid(
115
+ c2w,
116
+ frame_idx / num_frames,
117
+ hw_ratio=hw_ratio,
118
+ base_xval=base_xval,
119
+ zval=zval
120
+ )
121
+ img = self.draw()
122
+ writer.append_data(img)
123
+
124
+ writer.close()
125
+
126
+
127
+ def get_args():
128
+ parser = argparse.ArgumentParser()
129
+ parser.add_argument('--pose_file_path', required=True, help='path to the trajectory txt file')
130
+ parser.add_argument('--filter_file', required=True, help='path to the filter txt file')
131
+ parser.add_argument('--out_path', required=True, help='path to save the visualization results')
132
+ parser.add_argument('--num_videos', type=int, default=150, help='number of videos to visualize')
133
+ parser.add_argument('--hw_ratio', default=2/3, type=float, help='the height over width of the film plane')
134
+ parser.add_argument('--sample_stride', type=int, default=1)
135
+ parser.add_argument('--num_frames', type=int, default=81)
136
+ parser.add_argument('--all_frames', action='store_true')
137
+ parser.add_argument('--base_xval', type=float, default=0.25)
138
+ parser.add_argument('--zval', type=float, default=0.5)
139
+ parser.add_argument('--use_exact_fx', action='store_true')
140
+ parser.add_argument('--relative_c2w', action='store_true')
141
+ parser.add_argument('--x_min', type=float, default=-2)
142
+ parser.add_argument('--x_max', type=float, default=2)
143
+ parser.add_argument('--y_min', type=float, default=-2)
144
+ parser.add_argument('--y_max', type=float, default=2)
145
+ parser.add_argument('--z_min', type=float, default=-2)
146
+ parser.add_argument('--z_max', type=float, default=2)
147
+ parser.add_argument('--animate_camera', action='store_true')
148
+ parser.add_argument('--fps', type=int, default=16)
149
+ parser.add_argument('--keyframe_interval', type=int, default=10)
150
+ return parser.parse_args()
151
+
152
+
153
+ def get_c2w(w2cs, transform_matrix, relative_c2w):
154
+ if relative_c2w:
155
+ target_cam_c2w = np.array([
156
+ [1, 0, 0, 0],
157
+ [0, 1, 0, 0],
158
+ [0, 0, 1, 0],
159
+ [0, 0, 0, 1]
160
+ ])
161
+ abs2rel = target_cam_c2w @ w2cs[0]
162
+ ret_poses = [target_cam_c2w, ] + [abs2rel @ np.linalg.inv(w2c) for w2c in w2cs[1:]]
163
+ else:
164
+ ret_poses = [np.linalg.inv(w2c) for w2c in w2cs]
165
+ ret_poses = [transform_matrix @ x for x in ret_poses]
166
+ return np.array(ret_poses, dtype=np.float32)
167
+
168
+
169
+ if __name__ == '__main__':
170
+ args = get_args()
171
+ os.makedirs(args.out_path, exist_ok=True)
172
+ with open(args.filter_file, 'r') as f:
173
+ video_ids = f.read().splitlines()
174
+ video_ids = video_ids[:args.num_videos]
175
+ for video_id in tqdm(video_ids, desc='Visualizing camera poses'):
176
+ pose_file_path = os.path.join(args.pose_file_path, f'{video_id}.txt')
177
+ if not os.path.exists(pose_file_path):
178
+ print(f'Pose file {pose_file_path} does not exist, skip.')
179
+ continue
180
+ print(f'Visualizing {pose_file_path}...')
181
+
182
+ with open(pose_file_path, 'r') as f:
183
+ poses = f.readlines()
184
+ w2cs = [np.asarray([float(p) for p in pose.strip().split(' ')[7:]]).reshape(3, 4) for pose in poses[1:]]
185
+ fxs = [float(pose.strip().split(' ')[1]) for pose in poses[1:]]
186
+ if args.all_frames:
187
+ args.num_frames = len(fxs)
188
+ args.sample_stride = 1
189
+ cropped_length = args.num_frames * args.sample_stride
190
+ total_frames = len(w2cs)
191
+ start_frame_ind = 0
192
+ end_frame_ind = min(start_frame_ind + cropped_length, total_frames)
193
+ frame_ind = np.linspace(start_frame_ind, end_frame_ind - 1, args.num_frames, dtype=int)
194
+ w2cs = [w2cs[x] for x in frame_ind]
195
+ transform_matrix = np.asarray([[1, 0, 0, 0], [0, 0, 1, 0], [0, -1, 0, 0], [0, 0, 0, 1]]).reshape(4, 4)
196
+ last_row = np.zeros((1, 4))
197
+ last_row[0, -1] = 1.0
198
+ w2cs = [np.concatenate((w2c, last_row), axis=0) for w2c in w2cs]
199
+ c2ws = get_c2w(w2cs, transform_matrix, args.relative_c2w)
200
+
201
+ visualizer = CameraPoseVisualizer([args.x_min, args.x_max], [args.y_min, args.y_max], [args.z_min, args.z_max])
202
+ zval = fxs[0] if args.use_exact_fx else args.zval
203
+ if args.animate_camera:
204
+ out_file_path = os.path.join(args.out_path, f'{video_id}.mp4')
205
+ visualizer.anim_pose(
206
+ c2ws,
207
+ out_file_path,
208
+ args.hw_ratio,
209
+ args.base_xval,
210
+ zval,
211
+ args.fps,
212
+ args.keyframe_interval,
213
+ )
214
+ else:
215
+ out_file_path = os.path.join(args.out_path, f'{video_id}.png')
216
+ visualizer.vis_pose(
217
+ c2ws,
218
+ out_file_path,
219
+ args.hw_ratio,
220
+ args.base_xval,
221
+ zval
222
+ )