Add files using upload-large-folder tool
Browse files- UCPE/.gitignore +182 -0
- UCPE/CLAUDE.md +139 -0
- UCPE/README.md +425 -0
- UCPE/commands.sh +45 -0
- UCPE/demo/lens.json +74 -0
- UCPE/demo/pose.json +134 -0
- UCPE/demo/teaser.json +164 -0
- UCPE/diffsynth/__init__.py +6 -0
- UCPE/images/cameras.png +0 -0
- UCPE/images/orientation.png +0 -0
- UCPE/requirements.txt +36 -0
- UCPE/scripts/compare_panshot.sh +145 -0
- UCPE/scripts/demo.sh +11 -0
- UCPE/scripts/evaluate.sh +42 -0
- UCPE/scripts/inference.py +45 -0
- UCPE/scripts/predict.sh +22 -0
- UCPE/scripts/predict_one_sample.py +192 -0
- UCPE/scripts/set_Wan2.1-T2V-1.3B.sh +12 -0
- UCPE/scripts/set_Wan2.2-TI2V-5B.sh +13 -0
- UCPE/scripts/train.sh +66 -0
- UCPE/scripts/upload_704.sh +36 -0
- UCPE/setup.py +30 -0
- UCPE/src/cache.py +108 -0
- UCPE/src/camera_control.py +678 -0
- UCPE/src/dataset.py +432 -0
- UCPE/src/evaluate.py +870 -0
- UCPE/src/main.py +387 -0
- UCPE/thirdparty/GeoCalib/.gitattributes +1 -0
- UCPE/tools/align_panflow.py +599 -0
- UCPE/tools/caption_camerabench.py +195 -0
- UCPE/tools/caption_panshot.py +210 -0
- UCPE/tools/dataset_statistics.py +304 -0
- UCPE/tools/download_panflow.py +57 -0
- UCPE/tools/export_camerabench.py +64 -0
- UCPE/tools/export_camerabench_for_rerender.py +140 -0
- UCPE/tools/export_figure.py +192 -0
- UCPE/tools/export_table.py +261 -0
- UCPE/tools/extract_camerabench_poses.py +67 -0
- UCPE/tools/filter_camerabench.py +252 -0
- UCPE/tools/filter_panflow.py +254 -0
- UCPE/tools/geocalib_camerabench.py +46 -0
- UCPE/tools/match_panflow.py +376 -0
- UCPE/tools/normalize_panflow.py +200 -0
- UCPE/tools/pre_normalize_panflow.py +191 -0
- UCPE/tools/process_camerabench.py +195 -0
- UCPE/tools/process_panshot.py +470 -0
- UCPE/tools/rerender_panshot.py +328 -0
- UCPE/tools/score_panflow.py +244 -0
- UCPE/tools/visualize_pose.py +1231 -0
- 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 |
+
[](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 |
+
|
| 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 |
+
)
|