diff --git a/Dockerfile b/Dockerfile index 0a50ff6..55d295e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,15 @@ -FROM pytorch/pytorch:2.1.0-cuda11.8-cudnn8-devel +# check more: https://hub.docker.com/r/nvidia/cuda +FROM nvidia/cuda:11.7.1-devel-ubuntu20.04 ENV DEBIAN_FRONTEND noninteractive LABEL maintainer="Qingwen Zhang " -RUN apt update && apt install -y git tmux curl vim rsync libgl1 libglib2.0-0 ca-certificates +RUN apt update && apt install -y git curl vim rsync htop + +RUN curl -o ~/miniforge3.sh -LO https://github.com/conda-forge/miniforge/releases/latest/download/miniforge3-Linux-x86_64.sh && \ + chmod +x ~/miniforge3.sh && \ + ~/miniforge3.sh -b -p /opt/conda && \ + rm ~/miniforge3.sh && \ + /opt/conda/bin/conda clean -ya && /opt/conda/bin/conda init bash && /opt/conda/bin/conda init zsh # install zsh and oh-my-zsh RUN apt update && apt install -y wget git zsh tmux vim g++ @@ -14,23 +21,28 @@ RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/ -p https://github.com/zsh-users/zsh-syntax-highlighting RUN printf "y\ny\ny\n\n" | bash -c "$(curl -fsSL https://raw.githubusercontent.com/Kin-Zhang/Kin-Zhang/main/scripts/setup_ohmyzsh.sh)" -RUN /opt/conda/bin/conda init zsh # change to conda env ENV PATH /opt/conda/bin:$PATH -RUN /opt/conda/bin/conda config --set solver libmamba -RUN mkdir -p /home/kin/workspace && cd /home/kin/workspace && git clone https://github.com/Kin-Zhang/OpenSceneFlow +RUN mkdir -p /home/kin/workspace && cd /home/kin/workspace && git clone https://github.com/KTH-RPL/OpenSceneFlow.git WORKDIR /home/kin/workspace/OpenSceneFlow +RUN apt-get update && apt-get install libgl1 -y # need read the gpu device info to compile the cuda extension -RUN /opt/conda/bin/pip install -r /home/kin/workspace/OpenSceneFlow/requirements.txt -RUN /opt/conda/bin/pip install FastGeodis --no-build-isolation -RUN /opt/conda/bin/pip install --no-cache-dir -e ./assets/cuda/chamfer3D && /opt/conda/bin/pip install --no-cache-dir -e ./assets/cuda/mmcv +RUN cd /home/kin/workspace/OpenSceneFlow && /opt/conda/bin/conda env create -f environment.yaml +# To make images can run all methods in the codebase +RUN /opt/conda/envs/opensf/bin/pip install torch-scatter -f https://data.pyg.org/whl/torch-2.0.0+cu117.html +RUN /opt/conda/envs/opensf/bin/pip install FastGeodis --no-build-isolation --no-cache-dir +RUN /opt/conda/envs/opensf/bin/pip install mmengine-lite && \ + /opt/conda/bin/conda install -n opensf -y pytorch3d -c pytorch3d + +# custom cuda library +RUN cd /home/kin/workspace/OpenSceneFlow/assets/cuda/mmcv && /opt/conda/envs/opensf/bin/python ./setup.py install +RUN cd /home/kin/workspace/OpenSceneFlow/assets/cuda/chamfer3D && /opt/conda/envs/opensf/bin/python ./setup.py install -# environment for dataprocessing includes data-api -RUN /opt/conda/bin/conda env create -f envsftool.yaml +RUN cd /home/kin/workspace/OpenSceneFlow && /opt/conda/bin/conda env create -f envsftool.yaml RUN /opt/conda/envs/sftool/bin/pip install numpy==1.22 # clean up apt cache -RUN rm -rf /var/lib/apt/lists/* && rm -rf /root/.cache/pip +RUN rm -rf /var/lib/apt/lists/* && rm -rf /root/.cache/pip && /opt/conda/bin/conda clean -ya diff --git a/README.md b/README.md index a1aa2ae..ad357a0 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,20 @@ OpenSceneFlow is a codebase for point cloud scene flow estimation. It is also an official implementation of the following papers (sorted by the time of publication): +- **DeltaFlow: An Efficient Multi-frame Scene Flow Estimation Method** +*Qingwen Zhang, Xiaomeng Zhu, Yushan Zhang, Yixi Cai, Olov Andersson, Patric Jensfelt* +Preprint; Under review; 2025 +[ Backbone ] [ Supervised ] - [ [arXiv](https://arxiv.org/abs/2508.17054) ] [ [Project](https://github.com/Kin-Zhang/DeltaFlow) ] + - **HiMo: High-Speed Objects Motion Compensation in Point Clouds** (SeFlow++) *Qingwen Zhang, Ajinkya Khoche, Yi Yang, Li Ling, Sina Sharif Mansouri, Olov Andersson, Patric Jensfelt* -Preprint; Under review; 2025 -[ Strategy ] [ Self-Supervised ] - [ [arXiv](https://arxiv.org/abs/2503.00803) ] [ [Project](https://kin-zhang.github.io/HiMo/) ] +IEEE Transactions on Robotics (**T-RO**) 2025 +[ Strategy ] [ Self-Supervised ] - [ [arXiv](https://arxiv.org/abs/2503.00803) ] [ [Project](https://kin-zhang.github.io/HiMo/) ] → [here](#seflow-1) - **VoteFlow: Enforcing Local Rigidity in Self-Supervised Scene Flow** *Yancong Lin\*, Shiming Wang\*, Liangliang Nan, Julian Kooij, Holger Caesar* Conference on Computer Vision and Pattern Recognition (**CVPR**) 2025 -[ Backbone ] [ Self-Supervised ] - [ [arXiv](https://arxiv.org/abs/2503.22328) ] [ [Project](https://github.com/tudelft-iv/VoteFlow/)] → [here](#VoteFLow) +[ Backbone ] [ Self-Supervised ] - [ [arXiv](https://arxiv.org/abs/2503.22328) ] [ [Project](https://github.com/tudelft-iv/VoteFlow/) ] → [here](#VoteFLow) - **Flow4D: Leveraging 4D Voxel Network for LiDAR Scene Flow Estimation** *Jaeyeul Kim, Jungwan Woo, Ukcheol Shin, Jean Oh, Sunghoon Im* @@ -57,16 +62,6 @@ Additionally, *OpenSceneFlow* integrates following excellent works: [ICLR'24 Zer 💡: Want to learn how to add your own network in this structure? Check [Contribute section](CONTRIBUTING.md#adding-a-new-method) and know more about the code. Fee free to pull request and your bibtex [here](#cite-us). ---- - - - ## 0. Installation There are two ways to install the codebase: directly on your [local machine](#environment-setup) or in a [Docker container](#docker-recommended-for-isolation). @@ -77,7 +72,7 @@ We use conda to manage the environment, you can install it follow [here](assets/ ```bash git clone --recursive https://github.com/KTH-RPL/OpenSceneFlow.git -cd OpenSceneFlow && mamba env create -f environment.yaml +cd OpenSceneFlow && conda env create -f environment.yaml # You may need export your LD_LIBRARY_PATH with env lib # export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/kin/mambaforge/lib @@ -100,14 +95,14 @@ cd /home/kin/workspace/OpenSceneFlow && git pull cd /home/kin/workspace/OpenSceneFlow/assets/cuda/mmcv && /opt/conda/envs/opensf/bin/python ./setup.py install cd /home/kin/workspace/OpenSceneFlow/assets/cuda/chamfer3D && /opt/conda/envs/opensf/bin/python ./setup.py install cd /home/kin/workspace/OpenSceneFlow -mamba activate opensf +conda activate opensf ``` If you prefer to build the Docker image by yourself, Check [build-docker-image](assets/README.md#build-docker-image) section for more details. ## 1. Data Preparation -Refer to [dataprocess/README.md](dataprocess/README.md) for dataset download instructions. Currently, we support **Argoverse 2**, **Waymo**, **nuScenes** and **custom datasets** (more datasets will be added in the future). +Refer to [dataprocess/README.md](dataprocess/README.md) for dataset download instructions. Currently, we support **Argoverse 2**, **Waymo**, **nuScenes**, **ZOD** and **custom datasets** (more datasets will be added in the future). After downloading, convert the raw data to `.h5` format for easy training, evaluation, and visualization. Follow the steps in [dataprocess/README.md#process](dataprocess/README.md#process). @@ -115,8 +110,9 @@ For a quick start, use our **mini processed dataset**, which includes one scene ```bash -wget https://huggingface.co/kin-zhang/OpenSceneFlow/resolve/main/demo_data.zip -unzip demo_data.zip -d /home/kin/data/av2/h5py +# around 1.3G +wget https://huggingface.co/kin-zhang/OpenSceneFlow/resolve/main/demo-data-v2.zip +unzip demo-data-v2.zip -d /home/kin/data/av2/h5py ``` Once extracted, you can directly use this dataset to run the [training script](#2-quick-start) without further processing. @@ -131,56 +127,34 @@ Some tips before running the code: And free yourself from trainning, you can download the pretrained weight from [HuggingFace](https://huggingface.co/kin-zhang/OpenSceneFlow) and we provided the detail `wget` command in each model section. For optimization-based method, it's train-free so you can directly run with [3. Evaluation](#3-evaluation) (check more in the evaluation section). ```bash -mamba activate opensf +conda activate opensf ``` -### VoteFLow -Extra pakcges needed for VoteFlow, [pytorch3d](https://pytorch3d.org/) (prefer 0.7.7) and [torch-scatter](https://github.com/rusty1s/pytorch_scatter?tab=readme-ov-file) (prefer 2.1.2): - -```bash -# Install Pytorch3d -conda install pytorch3d -c pytorch3d +### Supervised Training -# Install torch-scatter -pip install torch-scatter -f https://data.pyg.org/whl/torch-2.0.0+cu117.html -``` - -Train VoteFlow with the leaderboard submit config. [Runtime: Around 32 hours in 4 x V100 GPUs.] -```bash -python train.py model=voteflow lr=2e-4 lr_scheduler=step epochs=12 batch_size=4 model.target.m=8 model.target.n=128 loss_fn=seflowLoss "add_seloss={chamfer_dis: 1.0, static_flow_loss: 1.0, dynamic_chamfer_dis: 1.0, cluster_based_pc0pc1: 1.0}" -``` - -Pretrained weight can be downloaded through: -```bash -wget https://huggingface.co/kin-zhang/OpenSceneFlow/resolve/main/voteflow_best.ckpt -``` - -### Flow4D +#### Flow4D Train Flow4D with the leaderboard submit config. [Runtime: Around 18 hours in 4x RTX 3090 GPUs.] ```bash -python train.py model=flow4d lr=1e-3 epochs=15 batch_size=8 num_frames=5 loss_fn=deflowLoss "voxel_size=[0.2, 0.2, 0.2]" "point_cloud_range=[-51.2, -51.2, -3.2, 51.2, 51.2, 3.2]" -``` +python train.py model=flow4d optimizer.lr=1e-3 epochs=15 batch_size=8 num_frames=5 loss_fn=deflowLoss "voxel_size=[0.2, 0.2, 0.2]" "point_cloud_range=[-51.2, -51.2, -3.2, 51.2, 51.2, 3.2]" -Pretrained weight can be downloaded through: -```bash +# Pretrained weight can be downloaded through: wget https://huggingface.co/kin-zhang/OpenSceneFlow/resolve/main/flow4d_best.ckpt ``` -### SSF +#### SSF Extra pakcges needed for SSF model: ```bash -pip install mmengine-lite torch-scatter -# torch-scatter might not working, then reinstall by: -pip install https://data.pyg.org/whl/torch-2.0.0%2Bcu118/torch_scatter-2.1.2%2Bpt20cu118-cp310-cp310-linux_x86_64.whl +pip install mmengine-lite +pip install torch-scatter -f https://data.pyg.org/whl/torch-2.0.0+cu117.html ``` Train SSF with the leaderboard submit config. [Runtime: Around 6 hours in 8x A100 GPUs.] ```bash -python train.py model=ssf lr=8e-3 epochs=25 batch_size=64 loss_fn=deflowLoss "voxel_size=[0.2, 0.2, 6]" "point_cloud_range=[-51.2, -51.2, -3, 51.2, 51.2, 3]" +python train.py model=ssf optimizer.lr=8e-3 epochs=25 batch_size=64 loss_fn=deflowLoss "voxel_size=[0.2, 0.2, 6]" "point_cloud_range=[-51.2, -51.2, -3, 51.2, 51.2, 3]" ``` Pretrained weight can be downloaded through: @@ -192,31 +166,61 @@ wget https://huggingface.co/kin-zhang/OpenSceneFlow/resolve/main/ssf_best.ckpt wget https://huggingface.co/kin-zhang/OpenSceneFlow/resolve/main/ssf_long.ckpt ``` +#### DeFlow -### SeFlow - -Train SeFlow needed to specify the loss function, we set the config of our best model in the leaderboard. [Runtime: Around 11 hours in 4x A100 GPUs.] +Train DeFlow with the leaderboard submit config. [Runtime: Around 6-8 hours in 4x A100 GPUs.] Please change `batch_size&lr` accoordingly if you don't have enough GPU memory. (e.g. `batch_size=6` for 24GB GPU) ```bash -python train.py model=deflow lr=2e-4 epochs=9 batch_size=16 loss_fn=seflowLoss "add_seloss={chamfer_dis: 1.0, static_flow_loss: 1.0, dynamic_chamfer_dis: 1.0, cluster_based_pc0pc1: 1.0}" "model.target.num_iters=2" +python train.py model=deflow optimizer.lr=2e-4 epochs=15 batch_size=16 loss_fn=deflowLoss + +# Pretrained weight can be downloaded through: +wget https://huggingface.co/kin-zhang/OpenSceneFlow/resolve/main/deflow_best.ckpt ``` -Pretrained weight can be downloaded through: +### Feed-Forward Self-Supervised Model Training + +Train Feed-forward SSL methods (e.g. SeFlow/SeFlow++/VoteFlow etc), we needed to: +1) process auto-label process. +2) specify the loss function, we set the config here for our best model in the leaderboard. + +#### SeFlow + ```bash +# [Runtime: Around 11 hours in 4x A100 GPUs.] +python train.py model=deflow optimizer.lr=2e-4 epochs=9 batch_size=16 loss_fn=seflowLoss +ssl_label=seflow_auto "+add_seloss={chamfer_dis: 1.0, static_flow_loss: 1.0, dynamic_chamfer_dis: 1.0, cluster_based_pc0pc1: 1.0}" "model.target.num_iters=2" + +# Pretrained weight can be downloaded through: wget https://huggingface.co/kin-zhang/OpenSceneFlow/resolve/main/seflow_best.ckpt ``` -### DeFlow +#### VoteFLow +Extra pakcges needed for VoteFlow, [pytorch3d](https://pytorch3d.org/) (prefer 0.7.7) and [torch-scatter](https://github.com/rusty1s/pytorch_scatter?tab=readme-ov-file) (prefer 2.1.2): -Train DeFlow with the leaderboard submit config. [Runtime: Around 6-8 hours in 4x A100 GPUs.] Please change `batch_size&lr` accoordingly if you don't have enough GPU memory. (e.g. `batch_size=6` for 24GB GPU) +```bash +# Install Pytorch3d +conda install pytorch3d -c pytorch3d +# Install torch-scatter +pip install torch-scatter -f https://data.pyg.org/whl/torch-2.0.0+cu117.html +``` + +Train VoteFlow with the leaderboard submit config. [Runtime: Around 32 hours in 4 x V100 GPUs.] ```bash -python train.py model=deflow lr=2e-4 epochs=15 batch_size=16 loss_fn=deflowLoss +python train.py model=voteflow optimizer.lr=2e-4 +optimizer.scheduler.name=StepLR +optimizer.scheduler.step_size=6 epochs=12 batch_size=4 model.target.m=8 model.target.n=128 loss_fn=seflowLoss "+add_seloss={chamfer_dis: 1.0, static_flow_loss: 1.0, dynamic_chamfer_dis: 1.0, cluster_based_pc0pc1: 1.0}" +ssl_label=seflow_auto + +# Pretrained weight can be downloaded through: +wget https://huggingface.co/kin-zhang/OpenSceneFlow/resolve/main/voteflow_best.ckpt ``` -Pretrained weight can be downloaded through: + +#### SeFlow++ + ```bash -wget https://huggingface.co/kin-zhang/OpenSceneFlow/resolve/main/deflow_best.ckpt +# [Runtime: Around 10 hours in 4x A100 GPUs.] for Argoverse 2 +python train.py model=deflowpp save_top_model=3 val_every=3 voxel_size="[0.2, 0.2, 6]" point_cloud_range="[-51.2, -51.2, -3, 51.2, 51.2, 3]" num_workers=16 epochs=9 optimizer.lr=2e-4 +optimizer.scheduler.name=StepLR "+add_seloss={chamfer_dis: 1.0, static_flow_loss: 1.0, dynamic_chamfer_dis: 1.0, cluster_based_pc0pc1: 1.0}" +ssl_label=seflowpp_auto loss_fn=seflowppLoss num_frames=3 batch_size=4 + +# Pretrained weight can be downloaded through: +wget https://huggingface.co/kin-zhang/OpenSceneFlow/resolve/main/seflowpp_best.ckpt ``` ## 3. Evaluation @@ -227,14 +231,14 @@ Since in training, we save all hyper-parameters and model checkpoints, the only ```bash # (feed-forward): load ckpt and run it, it will directly prints all metric -python eval.py checkpoint=/home/kin/seflow_best.ckpt av2_mode=val +python eval.py checkpoint=/home/kin/seflow_best.ckpt data_mode=val # (optimization-based): it might need take really long time, maybe tmux for run it. python eval.py model=nsfp # it will output the av2_submit.zip or av2_submit_v2.zip for you to submit to leaderboard -python eval.py checkpoint=/home/kin/seflow_best.ckpt av2_mode=test leaderboard_version=1 -python eval.py checkpoint=/home/kin/seflow_best.ckpt av2_mode=test leaderboard_version=2 +python eval.py checkpoint=/home/kin/seflow_best.ckpt data_mode=test leaderboard_version=1 +python eval.py checkpoint=/home/kin/seflow_best.ckpt data_mode=test leaderboard_version=2 ``` ### **📊 Range-Wise Metric (New!)** @@ -251,13 +255,13 @@ In [SSF paper](https://arxiv.org/abs/2501.17821), we introduce a new distance-ba ### Submit result to public leaderboard -To submit your result to the public Leaderboard, if you select `av2_mode=test`, it should be a zip file for you to submit to the leaderboard. +To submit your result to the public Leaderboard, if you select `data_mode=test`, it should be a zip file for you to submit to the leaderboard. Note: The leaderboard result in DeFlow&SeFlow main paper is [version 1](https://eval.ai/web/challenges/challenge-page/2010/evaluation), as [version 2](https://eval.ai/web/challenges/challenge-page/2210/overview) is updated after DeFlow&SeFlow. ```bash # since the env may conflict we set new on deflow, we directly create new one: -mamba create -n py37 python=3.7 -mamba activate py37 +conda create -n py37 python=3.7 +conda activate py37 pip install "evalai" # Step 2: login in eval and register your team diff --git a/assets/README.md b/assets/README.md index 837b01a..d49ebe8 100644 --- a/assets/README.md +++ b/assets/README.md @@ -80,6 +80,7 @@ python -c "import torch; print(torch.__version__); print(torch.cuda.is_available python -c "import lightning.pytorch as pl; print(pl.__version__)" python -c "from assets.cuda.mmcv import Voxelization, DynamicScatter;print('successfully import on our lite mmcv package')" python -c "from assets.cuda.chamfer3D import nnChamferDis;print('successfully import on our chamfer3D package')" +python -c "from av2.utils.io import read_feather; print('av2 package ok')" ``` @@ -100,3 +101,6 @@ python -c "from assets.cuda.chamfer3D import nnChamferDis;print('successfully im 3. torch_scatter problem: `OSError: /home/kin/mambaforge/envs/opensf-v2/lib/python3.10/site-packages/torch_scatter/_version_cpu.so: undefined symbol: _ZN5torch3jit17parseSchemaOrNameERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE` Solved by install the torch-cuda version: `pip install https://data.pyg.org/whl/torch-2.0.0%2Bcu118/torch_scatter-2.1.2%2Bpt20cu118-cp310-cp310-linux_x86_64.whl` + +4. cuda package problem: `ValueError(f"Unknown CUDA arch ({arch}) or GPU not supported")` + Solved by [checking GPU compute](https://developer.nvidia.cn/cuda-gpus#compute) then manually assign: `export TORCH_CUDA_ARCH_LIST=8.6` \ No newline at end of file diff --git a/assets/cuda/chamfer3D/__init__.py b/assets/cuda/chamfer3D/__init__.py index 3aac3a5..fc5020d 100644 --- a/assets/cuda/chamfer3D/__init__.py +++ b/assets/cuda/chamfer3D/__init__.py @@ -74,11 +74,11 @@ def dis_res(self, input0, input1): dist0, dist1, _, _ = ChamferDis.apply(input0, input1) return dist0, dist1 - def truncated_dis(self, input0, input1): + def truncated_dis(self, input0, input1, truncate_dist=2): # nsfp: truncated distance way is set >= 2 to 0 but not nanmean cham_x, cham_y = self.dis_res(input0, input1) - cham_x[cham_x >= 2] = 0.0 - cham_y[cham_y >= 2] = 0.0 + cham_x[cham_x >= truncate_dist] = 0.0 + cham_y[cham_y >= truncate_dist] = 0.0 return torch.mean(cham_x) + torch.mean(cham_y) def disid_res(self, input0, input1): diff --git a/assets/cuda/chamfer3D/setup.py b/assets/cuda/chamfer3D/setup.py index 2c16070..ed79970 100755 --- a/assets/cuda/chamfer3D/setup.py +++ b/assets/cuda/chamfer3D/setup.py @@ -1,15 +1,25 @@ from setuptools import setup from torch.utils.cpp_extension import BuildExtension, CUDAExtension +extra_compile_args = { + 'cxx': ['-DCCCL_IGNORE_DEPRECATED_CUDA_BELOW_12'], + 'nvcc': ['-DCCCL_IGNORE_DEPRECATED_CUDA_BELOW_12'], +} + setup( name='chamfer3D', ext_modules=[ - CUDAExtension('chamfer3D', [ - "/".join(__file__.split('/')[:-1] + ['chamfer3D_cuda.cpp']), # must named as xxx_cuda.cpp - "/".join(__file__.split('/')[:-1] + ['chamfer3D.cu']), - ]), + CUDAExtension( + name='chamfer3D', + sources=[ + "/".join(__file__.split('/')[:-1] + ['chamfer3D_cuda.cpp']), # must named as xxx_cuda.cpp + "/".join(__file__.split('/')[:-1] + ['chamfer3D.cu']), + ], + extra_compile_args=extra_compile_args + ), ], cmdclass={ 'build_ext': BuildExtension }, - version='1.0.1') + version='1.0.2' +) \ No newline at end of file diff --git a/assets/cuda/mmcv/setup.py b/assets/cuda/mmcv/setup.py index ba9140a..70473c5 100644 --- a/assets/cuda/mmcv/setup.py +++ b/assets/cuda/mmcv/setup.py @@ -3,9 +3,15 @@ from setuptools import find_packages, setup from torch.utils.cpp_extension import BuildExtension, CUDAExtension +extra_compile_args = { + 'cxx': ['-DCCCL_IGNORE_DEPRECATED_CUDA_BELOW_12'], + 'nvcc': ['-DCCCL_IGNORE_DEPRECATED_CUDA_BELOW_12'], +} + + setup( name='mmcv', - version='1.0.1', + version='1.0.2', ext_modules=[ CUDAExtension( name='mmcv', @@ -18,14 +24,8 @@ "/".join(__file__.split("/")[:-1] + ["pybind.cpp"]), ], - # extra_compile_args={ - # 'cxx': ['-std=c++17'], - # 'nvcc': ['-std=c++17', - # '-D__CUDA_NO_HALF_OPERATORS__', - # '-D__CUDA_NO_HALF_CONVERSIONS__', - # '-D__CUDA_NO_HALF2_OPERATORS__', - # ],} - ), + extra_compile_args=extra_compile_args + ), ], cmdclass={'build_ext': BuildExtension}, diff --git a/assets/slurm/0_process.sh b/assets/slurm/0_process.sh index 2b0392b..44481f2 100644 --- a/assets/slurm/0_process.sh +++ b/assets/slurm/0_process.sh @@ -7,29 +7,31 @@ #SBATCH -t 1-00:00:00 #SBATCH --mail-type=END,FAIL #SBATCH --mail-user=qingwen@kth.se -#SBATCH --output /proj/berzelius-2023-154/users/x_qinzh/workspace/SeFlow/logs/slurm/%J_data.out -#SBATCH --error /proj/berzelius-2023-154/users/x_qinzh/workspace/SeFlow/logs/slurm/%J_data.err +#SBATCH --output /proj/berzelius-2023-154/users/x_qinzh/workspace/OpenSceneFlow/logs/slurm/%J_data.out +#SBATCH --error /proj/berzelius-2023-154/users/x_qinzh/workspace/OpenSceneFlow/logs/slurm/%J_data.err -cd /proj/berzelius-2023-154/users/x_qinzh/workspace/SeFlow +PYTHON=/proj/berzelius-2023-154/users/x_qinzh/mambaforge/envs/sftool/bin/python export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/proj/berzelius-2023-154/users/x_qinzh/mambaforge/lib +cd /proj/berzelius-2023-364/users/x_qinzh/workspace/OpenSceneFlow # export HYDRA_FULL_ERROR=1 -/proj/berzelius-2023-154/users/x_qinzh/mambaforge/envs/dataprocess/bin/python dataprocess/extract_av2.py --nproc 64 \ + +$PYTHON dataprocess/extract_av2.py --nproc 64 \ --av2_type sensor \ --data_mode train \ --argo_dir /proj/berzelius-2023-154/users/x_qinzh/av2 \ - --output_dir /proj/berzelius-2023-364/users/x_qinzh/data/av2/preprocess_v2 + --output_dir /proj/berzelius-2023-364/users/x_qinzh/data/av2/h5py -/proj/berzelius-2023-154/users/x_qinzh/mambaforge/envs/dataprocess/bin/python dataprocess/extract_av2.py --nproc 64 \ +$PYTHON dataprocess/extract_av2.py --nproc 64 \ --av2_type sensor \ --data_mode val \ --argo_dir /proj/berzelius-2023-154/users/x_qinzh/av2 \ - --output_dir /proj/berzelius-2023-364/users/x_qinzh/data/av2/preprocess_v2 \ + --output_dir /proj/berzelius-2023-364/users/x_qinzh/data/av2/h5py \ --mask_dir /proj/berzelius-2023-154/users/x_qinzh/av2/3d_scene_flow -/proj/berzelius-2023-154/users/x_qinzh/mambaforge/envs/dataprocess/bin/python dataprocess/extract_av2.py --nproc 64 \ +$PYTHON dataprocess/extract_av2.py --nproc 64 \ --av2_type sensor \ --data_mode test \ --argo_dir /proj/berzelius-2023-154/users/x_qinzh/av2 \ - --output_dir /proj/berzelius-2023-364/users/x_qinzh/data/av2/preprocess_v2 \ + --output_dir /proj/berzelius-2023-364/users/x_qinzh/data/av2/h5py \ --mask_dir /proj/berzelius-2023-154/users/x_qinzh/av2/3d_scene_flow \ No newline at end of file diff --git a/assets/slurm/1_train.sh b/assets/slurm/1_train.sh index 72c0d92..dd99a8d 100644 --- a/assets/slurm/1_train.sh +++ b/assets/slurm/1_train.sh @@ -7,9 +7,13 @@ #SBATCH --output /proj/berzelius-2023-154/users/x_qinzh/seflow/logs/slurm/%J_seflow.out #SBATCH --error /proj/berzelius-2023-154/users/x_qinzh/seflow/logs/slurm/%J_seflow.err -cd /proj/berzelius-2023-154/users/x_qinzh/seflow +PYTHON=/proj/berzelius-2023-154/users/x_qinzh/mambaforge/envs/opensf/bin/python +export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/proj/berzelius-2023-154/users/x_qinzh/mambaforge/lib +cd /proj/berzelius-2023-364/users/x_qinzh/workspace/OpenSceneFlow -SOURCE="/proj/berzelius-2023-154/users/x_qinzh/data/av2/preprocess_v2" + +# ===> to transfer data into local node disk, it can be ignored. <=== +SOURCE="/proj/berzelius-2023-364/users/x_qinzh/data/av2/autolabel" DEST="/scratch/local/av2" SUBDIRS=("sensor/train" "sensor/val") @@ -24,14 +28,7 @@ elapsed=$((end_time - start_time)) echo "Copy ${SOURCE} to ${DEST} Total time: ${elapsed} seconds" echo "Start training..." -# ====> paper model = seflow_official -# /proj/berzelius-2023-154/users/x_qinzh/mambaforge/envs/seflow/bin/python train.py \ -# slurm_id=$SLURM_JOB_ID wandb_mode=online train_data=/scratch/local/av2/sensor/train val_data=/scratch/local/av2/sensor/val \ -# num_workers=16 model=deflow lr=2e-6 epochs=50 batch_size=20 "model.target.num_iters=2" "model.val_monitor=val/Dynamic/Mean" \ -# loss_fn=seflowLoss "add_seloss={chamfer_dis: 1.0, static_flow_loss: 1.0, dynamic_chamfer_dis: 1.0, cluster_based_pc0pc1: 1.0}" - # ====> leaderboard model = seflow_best -/proj/berzelius-2023-154/users/x_qinzh/mambaforge/envs/seflow/bin/python train.py \ - slurm_id=$SLURM_JOB_ID wandb_mode=online train_data=/scratch/local/av2/sensor/train val_data=/scratch/local/av2/sensor/val \ +$PYTHON train.py slurm_id=$SLURM_JOB_ID wandb_mode=online train_data=/scratch/local/av2/sensor/train val_data=/scratch/local/av2/sensor/val \ num_workers=16 model=deflow lr=2e-4 epochs=9 batch_size=16 "model.target.num_iters=2" "model.val_monitor=val/Dynamic/Mean" \ loss_fn=seflowLoss "add_seloss={chamfer_dis: 1.0, static_flow_loss: 1.0, dynamic_chamfer_dis: 1.0, cluster_based_pc0pc1: 1.0}" diff --git a/assets/slurm/2_eval.sh b/assets/slurm/2_eval.sh index 30684cd..1a57440 100644 --- a/assets/slurm/2_eval.sh +++ b/assets/slurm/2_eval.sh @@ -5,29 +5,16 @@ #SBATCH --output /proj/berzelius-2023-154/users/x_qinzh/seflow/logs/slurm/%J_eval.out #SBATCH --error /proj/berzelius-2023-154/users/x_qinzh/seflow/logs/slurm/%J_eval.err -cd /proj/berzelius-2023-154/users/x_qinzh/seflow -SOURCE="/proj/berzelius-2023-154/users/x_qinzh/av2/preprocess_v2" -DEST="/scratch/local/av2" -SUBDIRS=("sensor/val") +PYTHON=/proj/berzelius-2023-154/users/x_qinzh/mambaforge/envs/opensf/bin/python +export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/proj/berzelius-2023-154/users/x_qinzh/mambaforge/lib +cd /proj/berzelius-2023-364/users/x_qinzh/workspace/OpenSceneFlow -start_time=$(date +%s) -for dir in "${SUBDIRS[@]}"; do - mkdir -p "${DEST}/${dir}" - find "${SOURCE}/${dir}" -type f -print0 | xargs -0 -n1 -P16 cp -t "${DEST}/${dir}" & -done -wait -end_time=$(date +%s) -elapsed=$((end_time - start_time)) -echo "Copy ${SOURCE} to ${DEST} Total time: ${elapsed} seconds" -echo "Start training..." # ====> leaderboard model -# /proj/berzelius-2023-154/users/x_qinzh/mambaforge/envs/seflow/bin/python eval.py \ -# wandb_mode=online dataset_path=/scratch/local/av2/sensor \ +# $PYTHON eval.py wandb_mode=online dataset_path=/proj/berzelius-2023-364/users/x_qinzh/data/av2/autolabel data_mode=test \ # checkpoint=/proj/berzelius-2023-154/users/x_qinzh/seflow/logs/wandb/seflow-10086990/checkpoints/epoch_19_seflow.ckpt \ -# av2_mode=test save_res=True +# save_res=True -/proj/berzelius-2023-154/users/x_qinzh/mambaforge/envs/seflow/bin/python eval.py \ - wandb_mode=online dataset_path=/scratch/local/av2/sensor av2_mode=val \ +$PYTHON eval.py wandb_mode=online dataset_path=/proj/berzelius-2023-364/users/x_qinzh/data/av2/autolabel data_mode=val \ checkpoint=/proj/berzelius-2023-154/users/x_qinzh/seflow/logs/wandb/seflow-10086990/checkpoints/epoch_19_seflow.ckpt \ No newline at end of file diff --git a/conf/config.yaml b/conf/config.yaml index 4a80895..a9e68f0 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -26,10 +26,12 @@ epochs: 3 gradient_clip_val: 5.0 # optimizer ==> Adam -lr: 2e-6 -lr_scheduler: None # choices: [cosine, step, linear] -loss_fn: deflowLoss # choices: [ff3dLoss, zeroflowLoss, deflowLoss, seflowLoss] -add_seloss: # {chamfer_dis: 1.0, static_flow_loss: 1.0, dynamic_chamfer_dis: 1.0, cluster_based_pc0pc1: 1.0} +optimizer: + name: Adam # [Adam, AdamW] + lr: 1e-4 +loss_fn: seflowLoss # choices: [ff3dLoss, zeroflowLoss, deflowLoss, seflowLoss] +# add_seloss: {chamfer_dis: 1.0, static_flow_loss: 1.0, dynamic_chamfer_dis: 1.0, cluster_based_pc0pc1: 1.0} +# ssl_label: # log settings seed: 42069 diff --git a/conf/eval.yaml b/conf/eval.yaml index 2c65dbe..a6b35db 100644 --- a/conf/eval.yaml +++ b/conf/eval.yaml @@ -1,7 +1,7 @@ dataset_path: /home/kin/data/av2/h5py/sensor checkpoint: /home/kin/model_zoo/deflow.ckpt -av2_mode: val # [val, test] +data_mode: val # [val, test] save_res: False # [True, False] leaderboard_version: 1 # [1, 2] diff --git a/conf/ground/zod.toml b/conf/ground/zod.toml new file mode 100644 index 0000000..ab5e85b --- /dev/null +++ b/conf/ground/zod.toml @@ -0,0 +1,31 @@ + +[important] +height = 2.1 # sensor height. 雷达传感器高度,主要是+offset + +# 整个雷点点云以自己为中心 分为多少个segment,每个segment又分成多少个bin +[segments] +r_min = 0.1 # minimum point distance. 感兴趣的区域 +r_max = 100 # maximum point distance. +n_segments = 360 # number of radial segments. +n_bins = 160 # number of radial bins. + + +[ground] +# 直线的 slope 斜率,一个seg里拟合的线斜率 +min_slope = 0.0 # minimum slope of a ground line. [T_m_small] +max_slope = 0.1 # maximum slope of a ground line. [T_m] +# 如果拟合点超过这个 long threshold,判断其 height 变化是否超过 max_long_height +long_threshold = 2.0 # Distance at which points are considered far from each other. [T_d_prev] +max_long_height = 0.2 # maximum height change to previous point in long line. [T_b] +# 如果没有超过 long threshold,判断现在拟合的点与fitline高度变化是否超过 max_start_height +max_start_height = 0.2 # Maximum heigh of starting line to be labelled ground. +# 最大的拟合误差,超过这个误差的点不会被加入 line 拟合 +max_fit_error = 0.1 # maximum error of a point during line fit. [T_RMSE: will sqaure inside code.] + +# 判断是否是地面点的时候,需要满足的条件: +max_dist_to_line = 0.2 # maximum vertical distance of point to line to be considered ground. [T_d_ground] +line_search_angle = 2.0 # How far to search for a line in angular direction [rad]. + +[general] +n_threads = 8 # number of threads for parallel processing. +verbose = false # if you don't want to see every output, set this to false. \ No newline at end of file diff --git a/conf/model/deflowpp.yaml b/conf/model/deflowpp.yaml new file mode 100644 index 0000000..c896f18 --- /dev/null +++ b/conf/model/deflowpp.yaml @@ -0,0 +1,11 @@ +name: deflowpp + +target: + _target_: src.models.DeFlowPP + decoder_option: gru # choices: [linear, gru] + num_iters: 2 + voxel_size: ${voxel_size} + point_cloud_range: ${point_cloud_range} + num_frames: ${num_frames} + +val_monitor: val/Dynamic/Mean \ No newline at end of file diff --git a/conf/save.yaml b/conf/save.yaml index 0332482..5b2d5ad 100644 --- a/conf/save.yaml +++ b/conf/save.yaml @@ -1,4 +1,4 @@ -dataset_path: /home/kin/data/av2/h5py/demo/sensor/val +dataset_path: /home/kin/data/av2/h5py/demo/val checkpoint: /home/kin/model_zoo/seflow_best.ckpt res_name: # if None will directly be the `model_name.ckpt` in checkpoint path diff --git a/dataprocess/README.md b/dataprocess/README.md index 893af64..2651afa 100644 --- a/dataprocess/README.md +++ b/dataprocess/README.md @@ -11,8 +11,8 @@ We've updated the process dataset for: - [x] Argoverse 2.0: check [here](#argoverse-20). The process script Involved from [DeFlow](https://github.com/KTH-RPL/DeFlow). - [x] Waymo: check [here](#waymo-dataset). The process script was involved from [SeFlow](https://github.com/KTH-RPL/SeFlow). - [x] nuScenes: check [here](#nuscenes), The process script was involved from [DeltaFlow](https://github.com/Kin-Zhang/DeltaFlow). +- [x] ZOD (w/o gt): check [here](#zod-dataset). The process script was involved from [HiMo](https://kin-zhang.github.io/HiMo). (It could be a good first reference for users to extract other datasets in the future.) - [ ] TruckScene: done coding, public after review. Will be involved later by another paper. -- [ ] ZOD (w/o gt): done coding, public after review. Will be involved later by another paper. If you want to **use all datasets above**, there is a **specific environment** in [envsftool.yaml](../envsftool.yaml) to install all the necessary packages. As Waymo package have different configuration and conflict with the main environment. Setup through the following command: @@ -123,20 +123,35 @@ tar -xvf waymo_map.tar.gz -C /home/kin/data/waymo/flowlabel | train | 799 | 155687 | | val | 203 | 39381 | +### ZOD Dataset + +Although ZOD have the most dense LiDAR sensor (128-channel), the dataset itself **does not include ground truth flow**. +We provide the extraction script for Self-Supervised Learning (SSL) to train and visualize the results etc, again **no evaluation available** here. + +To download the ZOD dataset, you need follow [the instruction here](https://zod.zenseact.com/download/): send email and ask for the download from the team. + +For HiMo, we only downloaded [drives-set](https://zod.zenseact.com/drives/) for test purpose etc. The total drives-set includes 29 sequences (Total size: 303G). Here are [quick video play](https://www.bilibili.com/video/BV1Sh4y1z7v2) for each scene in the drives. + +Please check the scripts: [dataprocess/extract_zod.py](./extract_zod.py) in detail, current we only process one scene while feel free to comment out for all scene etc. + + + ## Process -This directory contains the scripts to preprocess the datasets. + +This directory contains the scripts to preprocess the datasets into `.h5` files. - `extract_av2.py`: Process the datasets in Argoverse 2.0. - `extract_nus.py`: Process the datasets in nuScenes. - `extract_waymo.py`: Process the datasets in Waymo. +- `extract_zod.py`: Process the datasets in ZOD. Example Running command: ```bash # av2: -python dataprocess/extract_av2.py --av2_type sensor --data_mode train --argo_dir /home/kin/data/av2 --output_dir /home/kin/data/av2/preprocess +python dataprocess/extract_av2.py --av2_type sensor --data_mode train --argo_dir /home/kin/data/av2 --output_dir /home/kin/data/av2/h5py # waymo: -python dataprocess/extract_waymo.py --mode train --flow_data_dir /home/kin/data/waymo/flowlabel --map_dir /home/kin/data/waymo/flowlabel/map --output_dir /home/kin/data/waymo/preprocess --nproc 48 +python dataprocess/extract_waymo.py --mode train --flow_data_dir /home/kin/data/waymo/flowlabel --map_dir /home/kin/data/waymo/flowlabel/map --output_dir /home/kin/data/waymo/h5py --nproc 48 # nus: python dataprocess/extract_nus.py --mode v1.0-trainval --output_dir /home/kin/data/nus/h5py/full --nproc 24 @@ -146,7 +161,7 @@ python dataprocess/extract_nus.py --mode v1.0-trainval --output_dir /home/kin/da Extract all Argoverse 2.0 data to unified `.h5` format. [Runtime: Normally need 45 mins finished run following commands totally in setup mentioned in our paper] ```bash -python dataprocess/extract_av2.py --av2_type sensor --data_mode train --argo_dir /home/kin/data/av2 --output_dir /home/kin/data/av2/preprocess_v2 +python dataprocess/extract_av2.py --av2_type sensor --data_mode train --argo_dir /home/kin/data/av2 --output_dir /home/kin/data/av2/h5py python dataprocess/extract_av2.py --av2_type sensor --data_mode val --mask_dir /home/kin/data/av2/3d_scene_flow python dataprocess/extract_av2.py --av2_type sensor --data_mode test --mask_dir /home/kin/data/av2/3d_scene_flow ``` @@ -175,9 +190,9 @@ Or you can run testing file to visualize the data. ```bash # view gt flow -python tools/visualization.py --data_dir /home/kin/data/av2/preprocess/sensor/mini --res_name flow +python tools/visualization.py --data_dir /home/kin/data/av2/h5py/sensor/mini --res_name flow -python tools/visualization.py --data_dir /home/kin/data/waymo/preprocess/val --res_name flow +python tools/visualization.py --data_dir /home/kin/data/waymo/h5py/val --res_name flow ``` ### Self-Supervised Process @@ -186,5 +201,5 @@ Process train data for self-supervised learning. Only training data needs this s [Runtime: Normally need 15 hours for my desktop, 3 hours for the cluster with five available nodes parallel running.] ```bash -python process.py --data_dir /home/kin/data/av2/preprocess_v2/sensor/train --scene_range 0,701 +python process.py --data_dir /home/kin/data/av2/h5py/sensor/train --scene_range 0,701 ``` diff --git a/dataprocess/extract_av2.py b/dataprocess/extract_av2.py index 188b03c..de45acb 100644 --- a/dataprocess/extract_av2.py +++ b/dataprocess/extract_av2.py @@ -15,6 +15,8 @@ import os os.environ["OMP_NUM_THREADS"] = "1" +import warnings +warnings.filterwarnings("ignore", category=UserWarning) from av2.datasets.sensor.av2_sensor_dataloader import convert_pose_dataframe_to_SE3 from av2.structures.sweep import Sweep @@ -40,7 +42,7 @@ import os, sys BASE_DIR = os.path.abspath(os.path.join( os.path.dirname( __file__ ), '..' )) sys.path.append(BASE_DIR) -from dataprocess.misc_data import create_reading_index +from dataprocess.misc_data import create_reading_index, check_h5py_file_exists from src.utils.av2_eval import read_ego_SE3_sensor BOUNDING_BOX_EXPANSION: Final = 0.2 @@ -92,15 +94,23 @@ def read_pose_pc_ground(data_dir: Path, log_id: str, timestamp: int, avm: Argove ego2sensor_pose = read_ego_SE3_sensor((data_dir / log_id))['up_lidar'] filtered_log_poses_df = log_poses_df[log_poses_df["timestamp_ns"].isin([timestamp])] pose = convert_pose_dataframe_to_SE3(filtered_log_poses_df.loc[filtered_log_poses_df["timestamp_ns"] == timestamp]) - pc = Sweep.from_feather(data_dir / log_id / "sensors" / "lidar" / f"{timestamp}.feather").xyz + av2_sweep = Sweep.from_feather(data_dir / log_id / "sensors" / "lidar" / f"{timestamp}.feather") + pc = av2_sweep.xyz + + # ref: https://github.com/argoverse/av2-api/issues/77 + lidar_id = np.zeros(len(pc), dtype=np.uint8) + lidar_id[av2_sweep.laser_number < 32] = 1 + lidar_id[av2_sweep.laser_number >= 32] = 2 + lidar_dt = av2_sweep.offset_ns / 1e9 # to second + # transform to city coordinate since sweeps[0].xyz is in ego coordinate to get ground mask is_ground = avm.get_ground_points_boolean(pose.transform_point_cloud(pc)) # NOTE(SeFlow): transform to sensor coordinate, since some ray-casting based methods need sensor coordinate pc = ego2sensor_pose.inverse().transform_point_cloud(pc) - return pc, pose, is_ground + return pc, lidar_id, lidar_dt, pose, is_ground -def compute_sceneflow(data_dir: Path, log_id: str, timestamps: Tuple[int, int]) -> Dict[str, Union[np.ndarray, SE3]]: +def compute_sceneflow(data_dir: Path, log_id: str, timestamps: Tuple[int, int], dclass) -> Dict[str, Union[np.ndarray, SE3]]: """Compute sceneflow between the sweeps at the given timestamps. Args: data_dir: Argoverse 2.0 directory, e.g. /home/kin/data/av2/sensor/train @@ -127,12 +137,11 @@ def compute_flow(sweeps, cuboids, poses): ego1_SE3_ego0.translation = ego1_SE3_ego0.translation.astype(np.float32) flow = ego1_SE3_ego0.transform_point_cloud(sweeps[0].xyz) - sweeps[0].xyz - # Convert to float32s flow = flow.astype(np.float32) valid = np.ones(len(sweeps[0].xyz), dtype=np.bool_) - # classes = -np.ones(len(sweeps[0].xyz), dtype=np.int8) classes = np.zeros(len(sweeps[0].xyz), dtype=np.uint8) + instances = np.zeros(len(sweeps[0].xyz), dtype=np.int16) # # old version # for id in cuboids[0]: @@ -173,9 +182,10 @@ def compute_flow(sweeps, cuboids, poses): obj_flow = c1_SE3_c0.transform_point_cloud(obj_pts) - obj_pts classes[obj_mask] = CATEGORY_TO_INDEX[str(c0.category)] flow[obj_mask] = obj_flow.astype(np.float32) + instances[obj_mask] = dclass[id]+1 else: valid[obj_mask] = 0 - return flow, classes, valid, ego1_SE3_ego0 + return flow, classes, valid, ego1_SE3_ego0, instances sweeps = [Sweep.from_feather(data_dir / log_id / "sensors" / "lidar" / f"{ts}.feather") for ts in timestamps] # ================== Load annotations ================== @@ -203,24 +213,28 @@ def compute_flow(sweeps, cuboids, poses): filtered_log_poses_df = log_poses_df[log_poses_df["timestamp_ns"].isin(timestamps)] poses = [convert_pose_dataframe_to_SE3(filtered_log_poses_df.loc[filtered_log_poses_df["timestamp_ns"] == ts]) for ts in timestamps] - flow_0_1, classes_0, valid_0, ego_motion = compute_flow(sweeps, cuboids, poses) + flow_0_1, classes_0, valid_0, ego_motion, instances = compute_flow(sweeps, cuboids, poses) return {'pcl_0': sweeps[0].xyz, 'pcl_1' :sweeps[1].xyz, 'flow_0_1': flow_0_1, 'valid_0': valid_0, 'classes_0': classes_0, - 'pose_0': poses[0], 'pose_1': poses[1], + 'pose_0': poses[0], 'pose_1': poses[1], 'instances': instances, 'ego_motion': ego_motion} def process_log(data_dir: Path, log_id: str, output_dir: Path, n: Optional[int] = None) : - def create_group_data(group, pc, gm, pose, flow_0to1=None, flow_valid=None, flow_category=None, ego_motion=None): + def create_group_data(group, pc, pc_id, pc_dt, gm, pose, flow_0to1=None, flow_valid=None, flow_category=None, flow_instance=None, ego_motion=None): group.create_dataset('lidar', data=pc.astype(np.float32)) group.create_dataset('ground_mask', data=gm.astype(bool)) group.create_dataset('pose', data=pose.astype(np.float32)) + # lidar_id for visualization and lidar_dt for HiMo mainly: + group.create_dataset('lidar_id', data=pc_id.astype(np.uint8)) # sensor id + group.create_dataset('lidar_dt', data=pc_dt.astype(np.float32)) # deltaT if flow_0to1 is not None: # ground truth flow information group.create_dataset('flow', data=flow_0to1.astype(np.float32)) group.create_dataset('flow_is_valid', data=flow_valid.astype(bool)) group.create_dataset('flow_category_indices', data=flow_category.astype(np.uint8)) + group.create_dataset('flow_instance_id', data=flow_instance.astype(np.int16)) group.create_dataset('ego_motion', data=ego_motion.astype(np.float32)) log_map_dirpath = data_dir / log_id / "map" @@ -235,29 +249,31 @@ def create_group_data(group, pc, gm, pose, flow_0to1=None, flow_valid=None, flow for file in os.listdir(data_dir / log_id / "sensors/lidar") if file.endswith('.feather')]) + gt_flow_flag = False if not (data_dir / log_id / "annotations.feather").exists() else True - + if check_h5py_file_exists(output_dir/f'{log_id}.h5', timestamps): + return # if n is not None: # iter_bar = tqdm(zip(timestamps, timestamps[1:]), leave=False, # total=len(timestamps) - 1, position=n, # desc=f'Log {log_id}') # else: # iter_bar = zip(timestamps, timestamps[1:]) - + dclass = defaultdict(lambda: len(dclass)) with h5py.File(output_dir/f'{log_id}.h5', 'a') as f: for cnt, ts0 in enumerate(timestamps): group = f.create_group(str(ts0)) - pc0, pose0, is_ground_0 = read_pose_pc_ground(data_dir, log_id, ts0, avm) + pc0, lidar_id0, lidar_dt0, pose0, is_ground_0 = read_pose_pc_ground(data_dir, log_id, ts0, avm) if pc0.shape[0] < 256: print(f'{log_id}/{ts0} has less than 256 points, skip this scenarios. Please check the data if needed.') break if cnt == len(timestamps) - 1 or not gt_flow_flag: - create_group_data(group, pc0, is_ground_0.astype(np.bool_), pose0.transform_matrix.astype(np.float32)) + create_group_data(group, pc0, lidar_id0, lidar_dt0, is_ground_0.astype(np.bool_), pose0.transform_matrix.astype(np.float32)) else: ts1 = timestamps[cnt + 1] - scene_flow = compute_sceneflow(data_dir, log_id, (ts0, ts1)) - create_group_data(group, pc0, is_ground_0.astype(np.bool_), pose0.transform_matrix.astype(np.float32), - scene_flow['flow_0_1'], scene_flow['valid_0'], scene_flow['classes_0'], + scene_flow = compute_sceneflow(data_dir, log_id, (ts0, ts1), dclass) + create_group_data(group, pc0, lidar_id0, lidar_dt0, is_ground_0.astype(np.bool_), pose0.transform_matrix.astype(np.float32), + scene_flow['flow_0_1'], scene_flow['valid_0'], scene_flow['classes_0'], scene_flow['instances'], scene_flow['ego_motion'].transform_matrix.astype(np.float32)) def proc(x, ignore_current_process=False): diff --git a/dataprocess/extract_zod.py b/dataprocess/extract_zod.py new file mode 100644 index 0000000..d60ecdd --- /dev/null +++ b/dataprocess/extract_zod.py @@ -0,0 +1,140 @@ +""" +# Created: 2024-07-07 22:18 +# Copyright (C) 2024-now, Scania Sverige EEARP Group +# Author: Qingwen Zhang (https://kin-zhang.github.io/) +# +# License: GPLv2, allow it free only for academic use. +# Description: Preprocess Data, save as h5df format for faster loading +""" + +import fire, time, os, sys, json, h5py +from pathlib import Path +from multiprocessing import current_process +from tqdm import tqdm +import multiprocessing +import numpy as np +from typing import Optional + +# import zod +from zod.constants import Camera, Lidar +from zod.data_classes.sequence import ZodSequence +from zod._zod_dataset import _create_frame + +BASE_DIR = os.path.abspath(os.path.join( os.path.dirname( __file__ ), '..')) +sys.path.append(BASE_DIR) +from dataprocess.misc_data import create_reading_index + +from linefit import ground_seg +GROUNDSEG_config = f"{BASE_DIR}/conf/ground/zod.toml" + +def process_log(data_dir: Path, log_id: str, output_dir: Path, n: Optional[int] = None) : + + def create_group_data(group, pc, pc_dt, pose, pc_id, gm=None, flow_0to1=None, flow_valid=None, flow_category=None, ego_motion=None): + group.create_dataset('lidar', data=pc.astype(np.float32)) + group.create_dataset('lidar_id', data=pc_id.astype(np.uint8)) # sensor id + group.create_dataset('lidar_dt', data=pc_dt.astype(np.float32)) # deltaT + group.create_dataset('pose', data=pose.astype(np.float32)) + if gm is not None: + group.create_dataset('ground_mask', data=gm.astype(np.bool_)) + if flow_0to1 is not None: + # ground truth flow information + group.create_dataset('flow', data=flow_0to1.astype(np.float32)) + group.create_dataset('flow_is_valid', data=flow_valid.astype(bool)) + group.create_dataset('flow_category_indices', data=flow_category.astype(np.uint8)) + group.create_dataset('ego_motion', data=ego_motion.astype(np.float32)) + + with open(data_dir / log_id / 'info.json', "r") as f: + frames = _create_frame(json.load(f), data_dir.as_posix().replace('drives', '')) + seq = ZodSequence(frames) + mygroundseg = ground_seg(GROUNDSEG_config) + with h5py.File(output_dir/f'{log_id}.h5', 'a') as f: + for cnt, frame in enumerate(seq.info.get_camera_lidar_map()): + + # NOTE(Qingwen): for a quick check downsampled data.. otherwise one h5py might have really long frames. + # FIXME(Qingwen): maybe split too long scene to mini-scene etc later? + if cnt % 4 != 0: + continue + + camera_frame, lidar_frame = frame + # img = camera_frame.read() + pcd = seq.get_compensated_lidar(camera_frame.time) + pose = seq.ego_motion.get_poses(camera_frame.time.timestamp()) + + diode_idx = pcd.diode_idx + lidar_id = np.zeros(diode_idx.shape, dtype=np.uint8) + + lidar_id[(diode_idx >= 0) & (diode_idx < 128)] = 1 + lidar_id[(diode_idx >= 128) & (diode_idx < 144)] = 2 + lidar_id[(diode_idx >= 144) & (diode_idx < 160)] = 3 + ego2sensor = seq._calibration.lidars[Lidar.VELODYNE].extrinsics + pose = pose @ ego2sensor.transform + lidar_timestamp = pcd.core_timestamp + lidar_dt = pcd.timestamps - lidar_timestamp + group = f.create_group(str(int(camera_frame.time.timestamp() * 10e6))) + + # NOTE(Qingwen): only need 128-channel long-range lidar, while if you want feel free to comment these. + # points = pcd.points # for all three lidars (1 long, 2 really short) + points = pcd.points[lidar_id == 1] + lidar_dt = lidar_dt[lidar_id == 1] + lidar_id = lidar_id[lidar_id == 1] + ground_mask = mygroundseg.run(points[:, :3]) + + create_group_data(group, points, lidar_dt, pose.astype(np.float32), lidar_id, gm=np.array(ground_mask, dtype=np.bool_)) + +def proc(x, ignore_current_process=False): + if not ignore_current_process: + current=current_process() + pos = current._identity[0] + else: + pos = 1 + process_log(*x, n=pos) + +def process_logs(data_dir: Path, output_dir: Path, nproc: int): + """Compute sceneflow for all logs in the dataset. Logs are processed in parallel. + Args: + data_dir: Argoverse 2.0 directory + output_dir: Output directory. + """ + + if not data_dir.exists(): + print(f'{data_dir} not found') + return + + # NOTE(Qingwen): if you don't want to all data_dir, then change here: logs = logs[:10] only 10 scene. + logs = os.listdir(data_dir) + # like here only 000018 scene. + logs = ['000018'] + args = sorted([(data_dir, log, output_dir) for log in logs]) + print(f'Using {nproc} processes to process data: {data_dir} to .h5 format. (#scenes: {len(args)})') + # for debug + for x in tqdm(args): + print(x) + proc(x, ignore_current_process=True) + break + + # comment out if you want to process all scene. + # if nproc <= 1: + # for x in tqdm(args, ncols=120): + # proc(x, ignore_current_process=True) + # else: + # with Pool(processes=nproc) as p: + # res = list(tqdm(p.imap_unordered(proc, args), total=len(logs), ncols=120)) + +def main( + dataset_root: str = "/home/kin/DATA_HDD/public_data/zod/drives", + output_dir: str ="/home/kin/data/zod/h5py/himo", + nproc: int = (multiprocessing.cpu_count() - 1), + only_index: bool = False, +): + output_dir_ = Path(output_dir) + if only_index: + create_reading_index(output_dir_) + return + output_dir_.mkdir(exist_ok=True, parents=True) + process_logs(Path(dataset_root), output_dir_, nproc) + create_reading_index(output_dir_) + +if __name__ == '__main__': + start_time = time.time() + fire.Fire(main) + print(f"\nRunning {__file__} used: {(time.time() - start_time)/60:.2f} mins") \ No newline at end of file diff --git a/environment.yaml b/environment.yaml index d520844..32212d4 100644 --- a/environment.yaml +++ b/environment.yaml @@ -3,13 +3,12 @@ channels: - conda-forge - pytorch dependencies: - - python=3.10 + - python=3.8 - pytorch::pytorch=2.0.0 - pytorch::torchvision - - pytorch::pytorch-cuda=11.8 - - conda-forge::lightning=2.0.1 - - nvidia/label/cuda-11.8.0::cuda - - nvidia/label/cuda-11.8.0::cuda-toolkit + - pytorch::pytorch-cuda=11.7 + - nvidia/label/cuda-11.7.0::cuda + - lightning==2.0.1 - mkl==2024.0.0 - tensorboard - numba @@ -24,7 +23,7 @@ dependencies: - hydra-core - fire - tabulate - - scikit-learn + - scikit-learn==1.3.2 - hdbscan - setuptools==69.5.1 - gxx_linux-64==11.4.0 @@ -34,18 +33,20 @@ dependencies: - assets/cuda/chamfer3D - assets/cuda/mmcv - open3d==0.18.0 - - av2==0.3.1 - - spconv-cu118==2.3.6 - - numpy==1.26.4 + - av2==0.2.1 + - spconv-cu117==2.3.6 + # Qingwen's public pkg: - dztimer - dufomap==1.1.0 - linefit==1.1.0 - + # Reason about the version fixed: # setuptools==68.5.1: https://github.com/aws-neuron/aws-neuron-sdk/issues/893 # mkl==2024.0.0: https://github.com/pytorch/pytorch/issues/123097#issue-2218541307 # av2==0.2.1: in case other version deleted some functions. # lightning==2.0.1: https://stackoverflow.com/questions/76647518/how-to-fix-error-cannot-import-name-modelmetaclass-from-pydantic-main # open3d==0.18.0: because 0.17.0 have bug on set the view json file -# dufomap==1.1.0: in case later updating may not compatible with the code. +# dufomap==1.0.0: in case later updating may not compatible with the code. +# spconv-cu117==2.3.6: avoid error: KeyError: ((16, 8, 8), float, float) +# torch-scatter==2.1.2: in case later updating may not compatible with the code. \ No newline at end of file diff --git a/envsftool.yaml b/envsftool.yaml index 8ac1925..887457a 100644 --- a/envsftool.yaml +++ b/envsftool.yaml @@ -6,6 +6,8 @@ dependencies: - python=3.8 - pytorch::pytorch=2.0.0 - pytorch::torchvision + - pytorch::pytorch-cuda=11.7 + - nvidia/label/cuda-11.7.0::cuda - mkl==2024.0.0 - numba - numpy==1.22 @@ -13,9 +15,9 @@ dependencies: - pip - scipy - tqdm - - scikit-learn + - scikit-learn=1.2.2 - fire - - hdbscan + - hdbscan=0.8.29 - s5cmd - pip: - nuscenes-devkit diff --git a/eval.py b/eval.py index bfe7bdd..bdceb08 100644 --- a/eval.py +++ b/eval.py @@ -21,8 +21,8 @@ from src.trainer import ModelWrapper def precheck_cfg_valid(cfg): - if os.path.exists(cfg.dataset_path + f"/{cfg.av2_mode}") is False: - raise ValueError(f"Dataset {cfg.dataset_path}/{cfg.av2_mode} does not exist. Please check the path.") + if os.path.exists(cfg.dataset_path + f"/{cfg.data_mode}") is False: + raise ValueError(f"Dataset {cfg.dataset_path}/{cfg.data_mode} does not exist. Please check the path.") if cfg.supervised_flag not in [True, False]: raise ValueError(f"Supervised flag {cfg.supervised_flag} is not valid. Please set it to True or False.") if cfg.leaderboard_version not in [1, 2]: @@ -37,7 +37,7 @@ def main(cfg): if 'iter_only' in cfg.model and cfg.model.iter_only: from src.runner import launch_runner print(f"---LOG[eval]: Run optmization-based method: {cfg.model.name}") - launch_runner(cfg, cfg.av2_mode) + launch_runner(cfg, cfg.data_mode) return if not os.path.exists(cfg.checkpoint): @@ -46,8 +46,9 @@ def main(cfg): torch_load_ckpt = torch.load(cfg.checkpoint) checkpoint_params = DictConfig(torch_load_ckpt["hyper_parameters"]) - cfg.output = checkpoint_params.cfg.output + f"-e{torch_load_ckpt['epoch']}-{cfg.av2_mode}-v{cfg.leaderboard_version}" + cfg.output = checkpoint_params.cfg.output + f"-e{torch_load_ckpt['epoch']}-{cfg.data_mode}-v{cfg.leaderboard_version}" cfg.model.update(checkpoint_params.cfg.model) + cfg.num_frames = cfg.model.target.get('num_frames', checkpoint_params.cfg.get('num_frames', cfg.get('num_frames', 2))) mymodel = ModelWrapper.load_from_checkpoint(cfg.checkpoint, cfg=cfg, eval=True) print(f"\n---LOG[eval]: Loaded model from {cfg.checkpoint}. The backbone network is {checkpoint_params.cfg.model.name}.\n") @@ -62,8 +63,8 @@ def main(cfg): # NOTE(Qingwen): search & check: def eval_only_step_(self, batch, res_dict) trainer.validate(model = mymodel, \ dataloaders = DataLoader( \ - HDF5Dataset(cfg.dataset_path + f"/{cfg.av2_mode}", \ - n_frames=checkpoint_params.cfg.num_frames if 'num_frames' in checkpoint_params.cfg else 2, \ + HDF5Dataset(cfg.dataset_path + f"/{cfg.data_mode}", \ + n_frames=cfg.num_frames, \ eval=True, leaderboard_version=cfg.leaderboard_version), \ batch_size=1, shuffle=False)) wandb.finish() diff --git a/process.py b/process.py index 076747d..90a6851 100644 --- a/process.py +++ b/process.py @@ -3,35 +3,60 @@ # Copyright (C) 2023-now, RPL, KTH Royal Institute of Technology # Author: Qingwen Zhang (https://kin-zhang.github.io/) # -# This file is part of SeFlow (https://github.com/KTH-RPL/SeFlow). +# This file is part of +# * HiMo (https://kin-zhang.github.io/HiMo). # If you find this repo helpful, please cite the respective publication as # listed on the above website. # -# Description: run dufomap on the dataset we preprocessed for afterward ssl training. -# it's only needed for ssl train but not inference. -# Goal to segment dynamic and static point roughly. +# Description: follow seflow & seflow++ idea but more to run: +# (a) dufomap; (b) linefit; (c) hdbscan; (d) nnd. +# """ from pathlib import Path from tqdm import tqdm import numpy as np -import fire, time, h5py, os +import fire, time, h5py, os, sys from hdbscan import HDBSCAN +from src.utils import npcal_pose0to1 from src.utils.mics import HDF5Data, transform_to_array from dufomap import dufomap +from linefit import ground_seg MIN_AXIS_RANGE = 2 # HARD CODED: remove ego vehicle points MAX_AXIS_RANGE = 50 # HARD CODED: remove far away points +BASE_DIR = os.path.abspath(os.path.join( os.path.dirname( __file__ ))) + +def check_data_key(filekey, keyname, scene_id, ts): + """ + Checks if a specified key exists in the given file-like object and deletes it if present. + + This is typically used to remove old or outdated data before overwriting with new data. + + Args: + filekey: The file-like object (e.g., HDF5 group or dictionary) to check for the key. + keyname: The name of the key to check and potentially delete. + scene_id: Identifier for the scene (used for logging or warning purposes). + ts: Timestamp or frame identifier (used for logging or warning purposes). + """ + if keyname in filekey: + # print(f"Warning: {scene_id} {ts} has {keyname}, old data will be removed and overwritten.") + del filekey[keyname] -def run_cluster( +def run_dufocluster( data_dir: str ="/home/kin/data/av2/preprocess/sensor/train", scene_range: list = [0, 1], - interval: int = 1, # useless here, just for the same interface args - overwrite: bool = False, + interval: int = 1, # interval frames to run dufomap only + overwrite: bool = True, + + # NOTE (Qingwen): following is for ground segmentation only... at least for code now. + tag: str = "av2", # [nus, zod, man, sca] etc + run_gm: bool = False, # run ground segmentation + min_nnd: float = 0.14, # min nnd distance 1.4m/s pedestrain speed; For Scania data, we set 0.32 here. ): data_path = Path(data_dir) - dataset = HDF5Data(data_path) + dataset = HDF5Data(data_path) # single frame reading. all_scene_ids = list(dataset.scene_id_bounds.keys()) for scene_in_data_index, scene_id in enumerate(all_scene_ids): start_time = time.time() @@ -43,7 +68,7 @@ def run_cluster( with h5py.File(os.path.join(data_path, f'{scene_id}.h5'), 'r+') as f: for ii in range(bounds["min_index"], bounds["max_index"]+1): key = str(dataset[ii]['timestamp']) - if 'label' not in f[key]: + if 'dufocluster' not in f[key]: flag_exist_label = False break if flag_exist_label and not overwrite: @@ -56,35 +81,54 @@ def run_cluster( pc0 = data['pc0'][:,:3] cluster_label = np.zeros(pc0.shape[0], dtype= np.int16) - if "dufo_label" not in data: - print(f"Warning: {scene_id} {data['timestamp']} has no dufo_label, will be skipped. Better to rerun dufomap again in this scene.") + if "dufo" not in data: + print(f"Warning: {scene_id} {data['timestamp']} has no dufo, will be skipped. Better to rerun dufomap again in this scene.") continue - elif data["dufo_label"].sum() < 20: + elif data["dufo"].sum() < 20: print(f"Warning: {scene_id} {data['timestamp']} has no dynamic points, will be skipped. Better to check this scene.") else: - hdb.fit(pc0[data["dufo_label"]==1]) + hdb.fit(pc0[data["dufo"]==1]) # NOTE(Qingwen): since -1 will be assigned if no cluster. We set it to 0. - cluster_label[data["dufo_label"]==1] = hdb.labels_ + 1 + cluster_label[data["dufo"]==1] = hdb.labels_ + 1 # save labels timestamp = data['timestamp'] key = str(timestamp) with h5py.File(os.path.join(data_path, f'{scene_id}.h5'), 'r+') as f: - if 'label' in f[key]: + if 'dufocluster' in f[key]: # print(f"Warning: {scene_id} {timestamp} has label, will be overwritten.") - del f[key]['label'] - f[key].create_dataset('label', data=np.array(cluster_label).astype(np.int16)) + del f[key]['dufocluster'] + f[key].create_dataset('dufocluster', data=np.array(cluster_label).astype(np.int16)) print(f"==> Scene {scene_id} finished, used: {(time.time() - start_time)/60:.2f} mins") - print(f"Data inside {str(data_path)} finished. Check the result with tools/visulization.py if you want to visualize them.") + print(f"Data inside {str(data_path)} finished. Check the result with tools/visulization.py if you want to visualize them.\n") -def run_dufo( +# since it's the only one need cuda to do. in case you want to run two single jobs. +def run_nnd( data_dir: str ="/home/kin/data/av2/preprocess/sensor/train", scene_range: list = [0, 1], - interval: int = 1, # interval frames to run dufomap - overwrite: bool = False, + interval: int = 1, # interval frames to run dufomap only + overwrite: bool = True, + + # NOTE (Qingwen): following is for ground segmentation only... at least for code now. + tag: str = "av2", # [nus, zod, man, sca] etc + run_gm: bool = False, # run ground segmentation + min_nnd: float = 0.14, # min nnd distance 1.4m/s pedestrain speed; For Scania data, we set 0.32 here. ): + # nnd function + from assets.cuda.chamfer3D import nnChamferDis + MyCUDAChamferDis = nnChamferDis() + import torch + # exit() + def cuda_nnd(pc0: torch.tensor, pc1: torch.tensor, moving_threshold=0.14, truncated=4.4): # 4.4 ~= 160km/h * 0.1s + # pc0: (N,3), already ego motion transformed; pc1: (M,3) + pow2_dist0, _= MyCUDAChamferDis.dis_res(pc0, pc1) + pow2_dist0 = pow2_dist0.cpu().numpy() + label = np.zeros(pc0.shape[0], dtype=np.uint8) + label[(pow2_dist0>=pow(moving_threshold,2)) & (pow2_dist0= scene_range[1]): continue bounds = dataset.scene_id_bounds[scene_id] - flag_has_dufo_label = True - with h5py.File(os.path.join(data_path, f'{scene_id}.h5'), 'r+') as f: + exist_dict = {"nnd": True} + with h5py.File(os.path.join(data_path, f'{scene_id}.h5'), 'r') as f: for ii in range(bounds["min_index"], bounds["max_index"]+1): key = str(dataset[ii]['timestamp']) - if "dufo_label" not in f[key]: - flag_has_dufo_label = False + for datakey in exist_dict.keys(): + if datakey not in f[key]: + exist_dict[datakey] = False + if not all(exist_dict.values()): break - if flag_has_dufo_label and not overwrite: - print(f"==> Scene {scene_id} has dufo_label, skip.") + + if all(exist_dict.values()) and not overwrite: + print(f"==> Scene {scene_id} already processed, skip.") continue - mydufo = dufomap(0.2, 0.2, 1, num_threads=12) # resolution, d_s, d_p, hit_extension - mydufo.setCluster(0, 20, 0.2) # depth=0, min_points=20, max_dist=0.2 + for data_id in tqdm(range(bounds["min_index"], bounds["max_index"]+1), desc=f"CUDA nnd run: {scene_in_data_index+1}/{len(all_scene_ids)}", ncols=80): + data = dataset[data_id] + pc0 = data['pc0'][:,:3] + timestamp = data['timestamp'] + key = str(timestamp) - print(f"==> Scene {scene_id} start, data path: {data_path}") - for i in tqdm(range(bounds["min_index"], bounds["max_index"]+1), desc=f"Dufo run: {scene_in_data_index}/{len(all_scene_ids)}", ncols=80): - if interval != 1 and i % interval != 0 and (i + interval//2 < bounds["max_index"] or i - interval//2 > bounds["min_index"]): - continue - data = dataset[i] - assert data['scene_id'] == scene_id, f"Check the data, scene_id {scene_id} is not consistent in {i}th data in {scene_in_data_index}th scene." - # HARD CODED: remove points outside the range - norm_pc0 = np.linalg.norm(data['pc0'][:, :3], axis=1) - range_mask = ( - (norm_pc0>MIN_AXIS_RANGE) & - (norm_pc0= scene_range[1]): + continue + bounds = dataset.scene_id_bounds[scene_id] + # If you don't want to seflowpp label, then remove cluster: True here. It won't process then. + exist_dict = {"dufo": True, "ground_mask": True, "cluster": True} + with h5py.File(os.path.join(data_path, f'{scene_id}.h5'), 'r') as f: + for ii in range(bounds["min_index"], bounds["max_index"]+1): + key = str(dataset[ii]['timestamp']) + for datakey in exist_dict.keys(): + if datakey not in f[key]: + exist_dict[datakey] = False + if not all(exist_dict.values()): + break + + if all(exist_dict.values()) and not overwrite: + print(f"==> Scene {scene_id} already processed, skip.") + continue + + # double check + if not exist_dict["ground_mask"] or run_gm: + mygroundseg = ground_seg(gm_config_path) + elif not exist_dict["ground_mask"] and not run_gm: + raise ValueError("You set run_gm=False, but ground segmentation is not done. Please check the code.") + exist_dict["ground_mask"] = exist_dict["ground_mask"] and not run_gm # and not overwrite # this overwrite is for debug mainly. + + # if overwrite: + # exist_dict["nnd"] = False + # assign all exist_dict to False, so we can run all the process again. + + if 'cluster' in exist_dict and not exist_dict["cluster"]: + hdbscan_cluster = HDBSCAN(min_cluster_size=20, cluster_selection_epsilon=0.7, alpha=1.1) + + if not exist_dict["dufo"]: + mydufo = dufomap(0.2, 0.2, 1, num_threads=12) # resolution, d_s, d_p, hit_extension + mydufo.setCluster(0, 20, 0.2) # depth=0, min_points=20, max_dist=0.2 + + print(f"==> Scene {scene_id} start, data path: {data_path}") + for i in tqdm(range(bounds["min_index"], bounds["max_index"]+1), desc=f"Dufo run: {scene_in_data_index+1}/{len(all_scene_ids)}", ncols=80): + if interval != 1 and i % interval != 0 and (i + interval//2 < bounds["max_index"] or i - interval//2 > bounds["min_index"]): + continue + data = dataset[i] + assert data['scene_id'] == scene_id, f"Check the data, scene_id {scene_id} is not consistent in {i}th data in {scene_in_data_index}th scene." + # HARD CODED: remove points outside the range + norm_pc0 = np.linalg.norm(data['pc0'][:, :3], axis=1) + range_mask = ( + (norm_pc0>MIN_AXIS_RANGE) & + (norm_pc0 Scene {scene_id} finished, used: {(time.time() - start_time)/60:.2f} mins") - print(f"Data inside {str(data_path)} finished. Check the result with vis() function if you want to visualize them.") - + if __name__ == '__main__': start_time = time.time() - # step 1: run dufomap - fire.Fire(run_dufo) - # step 2: run cluster on dufolabel - fire.Fire(run_cluster) - - print(f"\nTime used: {(time.time() - start_time)/60:.2f} mins") \ No newline at end of file + fire.Fire(main) + fire.Fire(run_dufocluster) + print("\nAlready Finished the main labels: dufo, cluster, ground mask etc.\n") + # fire.Fire(run_nnd) + print(f"\nScript Time used: {(time.time() - start_time)/60:.2f} mins") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index cbce091..0000000 --- a/requirements.txt +++ /dev/null @@ -1,32 +0,0 @@ -# A minimal env for training env only. -# --index-url https://download.pytorch.org/whl/cu118 -# torch==2.0.0 -# torchvision==0.15.1 -# torchaudio==2.0.1 - -lightning -tensorboard -wandb -tqdm -fire -omegaconf -hydra-core -tabulate -pathtools -numpy==1.26.0 -scipy -numba -pandas -scikit-learn -h5py -hdbscan -av2==0.3.4 -spconv-cu118==2.3.8 -flash-attn==2.6.3 -mmengine-lite -torch-scatter==2.1.2 - -# Qingwen's public pkg -dufomap==1.1.0 -linefit==1.1.0 -dztimer diff --git a/save.py b/save.py index b9195e6..5746eed 100644 --- a/save.py +++ b/save.py @@ -14,7 +14,7 @@ import torch from torch.utils.data import DataLoader import lightning.pytorch as pl -from lightning.pytorch.loggers import WandbLogger +from lightning.pytorch.loggers import TensorBoardLogger from omegaconf import DictConfig, OmegaConf import hydra, wandb, os, sys from hydra.core.hydra_config import HydraConfig @@ -45,21 +45,17 @@ def main(cfg): checkpoint_params = DictConfig(torch.load(cfg.checkpoint)["hyper_parameters"]) cfg.output = checkpoint_params.cfg.output cfg.model.update(checkpoint_params.cfg.model) + cfg.num_frames = cfg.model.target.get('num_frames', checkpoint_params.cfg.get('num_frames', cfg.get('num_frames', 2))) mymodel = ModelWrapper.load_from_checkpoint(cfg.checkpoint, cfg=cfg, eval=True) - wandb_logger = WandbLogger(save_dir=output_dir, - entity="kth-rpl", - project=f"deflow-eval", - name=f"{cfg.output}", - offline=True) - - trainer = pl.Trainer(logger=wandb_logger, devices=1) + logger = TensorBoardLogger(save_dir=output_dir, name="logs") + + trainer = pl.Trainer(logger=logger, devices=1) # NOTE(Qingwen): search & check in pl_model.py : def test_step(self, batch, res_dict) trainer.test(model = mymodel, \ dataloaders = DataLoader(\ - HDF5Dataset(cfg.dataset_path, n_frames=checkpoint_params.cfg.num_frames if 'num_frames' in checkpoint_params.cfg else 2), \ + HDF5Dataset(cfg.dataset_path, n_frames=cfg.num_frames), \ batch_size=1, shuffle=False)) - wandb.finish() if __name__ == "__main__": main() \ No newline at end of file diff --git a/src/autolabel.py b/src/autolabel.py new file mode 100644 index 0000000..736bbb6 --- /dev/null +++ b/src/autolabel.py @@ -0,0 +1,62 @@ +""" +# Created: 2024-02-22 22:05 +# Updated: 2025-08-20 21:45 +# Copyright (C) 2024-now, Scania Sverige EEARP Group, KTH Royal Institute of Technology +# Author: Qingwen ZHANG (https://kin-zhang.github.io/) +# License: GPLv2, allow it free only for academic use. +# +# Change Logs: +# 2024-02-22: clean up from HiMo project. +# +# Description: Label strategy for self-supervised learning (SSL) scene flow estimation. +# +""" + +import numpy as np +from copy import deepcopy + +def shiftClusterid(cluster): + """ + Shift the cluster labels by 1: + 0: background, no cluster id + 1: save this label for no cluster_id but dynamic + 2+: cluster id + """ + shifted_cluster = np.zeros_like(cluster) + mask = cluster > 0 + shifted_cluster[mask] = cluster[mask] + 1 + return shifted_cluster + +# mainly based on dufo with label inside for cluster-loss. Check HiMo Fig. 6 Top +def seflow_auto(input_data): + dufo = input_data['dufo'][:].astype(np.uint8) + cluster = shiftClusterid(input_data['dufocluster'][:].astype(np.int16)) + cluster[dufo == 0] = 0 + return cluster + +# based on dufo and nnd for dynamic then cluster-wise checking with reassign. Check HiMo Fig. 6 Bottom +def seflowpp_auto(input_data, tau1=0.05, tau2=0.30): + """ + check HiMo paper (Eq. 5) to know more about paramter setting here. + We didn't explore this parameter too much feel free to adjust as you want after reading the paper. + * For highway Scania data we set tau1=0.01, tau2=0.05 + * For urban Argoverse 2 data we set tau1=0.05 tau2=0.3 + """ + dufo = input_data['dufo'][:].astype(np.uint8) + cluster = shiftClusterid(input_data['cluster'][:].astype(np.int16)) + nnd = input_data['nnd'][:].astype(np.uint8) + + dynamic = np.zeros_like(dufo) + for cluster_id in np.unique(cluster): + if cluster_id in [0, 1]: + continue + all_pts = np.sum(cluster == cluster_id) + cluster_dufo = dufo[cluster == cluster_id] + cluster_nnd = nnd[cluster == cluster_id] + + r_dufo = np.sum(cluster_dufo>0)/all_pts + r_nnd = np.sum(cluster_nnd>0)/all_pts + + if min(r_dufo, r_nnd) > tau1 and max(r_dufo, r_nnd) > tau2: + dynamic[cluster == cluster_id] = cluster_id + return dynamic diff --git a/src/dataset.py b/src/dataset.py index acb514a..778d397 100644 --- a/src/dataset.py +++ b/src/dataset.py @@ -14,14 +14,18 @@ import torch from torch.utils.data import Dataset, DataLoader -import h5py, os, pickle, argparse, sys +import h5py, pickle, argparse from tqdm import tqdm + +import os, sys BASE_DIR = os.path.abspath(os.path.join( os.path.dirname( __file__ ), '..' )) sys.path.append(BASE_DIR) +from src.utils import import_func import warnings warnings.filterwarnings("ignore", category=UserWarning) +# FIXME(Qingwen 2025-08-20): update more pretty here afterward! def collate_fn_pad(batch): num_frames = 2 @@ -73,27 +77,38 @@ def collate_fn_pad(batch): pc1_dynamic_after_mask_ground = torch.nn.utils.rnn.pad_sequence(pc1_dynamic_after_mask_ground, batch_first=True, padding_value=0) res_dict['pc0_dynamic'] = pc0_dynamic_after_mask_ground res_dict['pc1_dynamic'] = pc1_dynamic_after_mask_ground + if 'pch1_dynamic' in batch[0]: + pch1_dynamic_after_mask_ground = [] + for i in range(len(batch)): + pch1_dynamic_after_mask_ground.append(batch[i]['pch1_dynamic'][~batch[i]['gmh1']]) + pch1_dynamic_after_mask_ground = torch.nn.utils.rnn.pad_sequence(pch1_dynamic_after_mask_ground, batch_first=True, padding_value=0) + res_dict['pch1_dynamic'] = pch1_dynamic_after_mask_ground return res_dict + class HDF5Dataset(Dataset): - def __init__(self, directory, n_frames=2, dufo=False, eval = False, leaderboard_version=1): + def __init__(self, directory, n_frames=2, ssl_label=None, eval = False, leaderboard_version=1): ''' - directory: the directory of the dataset - n_frames: the number of frames we use, default is 2: current, next if more then it's the history from current. - dufo: if True, we will read the dynamic cluster label - eval: if True, use the eval index + Args: + directory: the directory of the dataset, the folder should contain some .h5 file and index_total.pkl. + + Following are optional: + * n_frames: the number of frames we use, default is 2: from pc0 to pc1. + * ssl_label: if not None, we will use this label for self-supervised learning + * eval: if True, use the eval index + * leaderboard_version: 1st or 2nd, default is 1. If '2', we will use the index_eval_v2.pkl from assets/docs. ''' super(HDF5Dataset, self).__init__() self.directory = directory if (torch.distributed.is_initialized() and torch.distributed.get_rank() == 0) or not torch.distributed.is_initialized(): - print(f"----[Debug] Loading data with num_frames={n_frames}, eval={eval}, leaderboard_version={leaderboard_version}") + print(f"----[Debug] Loading data with num_frames={n_frames}, ssl_label={ssl_label}, eval={eval}, leaderboard_version={leaderboard_version}") with open(os.path.join(self.directory, 'index_total.pkl'), 'rb') as f: self.data_index = pickle.load(f) self.eval_index = False - self.dufo = dufo - self.n_frames = n_frames + self.ssl_label = import_func(f"src.autolabel.{ssl_label}") if ssl_label is not None else None + self.history_frames = n_frames - 2 if eval: eval_index_file = os.path.join(self.directory, 'index_eval.pkl') @@ -165,11 +180,10 @@ def __getitem__(self, index_): 'pose1': pose1, } - if self.n_frames > 2: + if self.history_frames > 0: past_frames = [] - num_past_frames = self.n_frames - 2 - for i in range(1, num_past_frames + 1): + for i in range(1, self.history_frames + 1): frame_index = index_ - i if frame_index < self.scene_id_bounds[scene_id]["min_index"]: frame_index = self.scene_id_bounds[scene_id]["min_index"] @@ -179,12 +193,14 @@ def __getitem__(self, index_): past_gm = torch.tensor(f[past_timestamp]['ground_mask'][:]) past_pose = torch.tensor(f[past_timestamp]['pose'][:]) - past_frames.append((past_pc, past_gm, past_pose)) + past_frames.append((past_pc, past_gm, past_pose, past_timestamp)) - for i, (past_pc, past_gm, past_pose) in enumerate(past_frames): + for i, (past_pc, past_gm, past_pose, past_timestamp) in enumerate(past_frames): res_dict[f'pch{i+1}'] = past_pc res_dict[f'gmh{i+1}'] = past_gm res_dict[f'poseh{i+1}'] = past_pose + if self.ssl_label is not None: + res_dict[f'pch{i+1}_dynamic'] = torch.tensor(self.ssl_label(f[past_timestamp]).astype('int16')) if 'flow' in f[key]: flow = torch.tensor(f[key]['flow'][:]) @@ -198,9 +214,9 @@ def __getitem__(self, index_): ego_motion = torch.tensor(f[key]['ego_motion'][:]) res_dict['ego_motion'] = ego_motion - if self.dufo: - res_dict['pc0_dynamic'] = torch.tensor(f[key]['label'][:].astype('int16')) - res_dict['pc1_dynamic'] = torch.tensor(f[next_timestamp]['label'][:].astype('int16')) + if self.ssl_label is not None: + res_dict['pc0_dynamic'] = torch.tensor(self.ssl_label(f[key]).astype('int16')) + res_dict['pc1_dynamic'] = torch.tensor(self.ssl_label(f[next_timestamp]).astype('int16')) if self.eval_index: # looks like v2 not follow the same rule as v1 with eval_mask provided diff --git a/src/lossfuncs.py b/src/lossfuncs.py deleted file mode 100644 index cd161d9..0000000 --- a/src/lossfuncs.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -# Created: 2023-07-17 00:00 -# Copyright (C) 2023-now, RPL, KTH Royal Institute of Technology -# Author: Qingwen Zhang (https://kin-zhang.github.io/) -# -# This file is part of DeFlow (https://github.com/KTH-RPL/DeFlow) and SeFlow (https://github.com/KTH-RPL/SeFlow). -# If you find this repo helpful, please cite the respective publication as -# listed on the above website. -# -# Description: Define the loss function for training. -""" -import torch -from assets.cuda.chamfer3D import nnChamferDis -MyCUDAChamferDis = nnChamferDis() -from src.utils.av2_eval import CATEGORY_TO_INDEX, BUCKETED_METACATAGORIES - -# NOTE(Qingwen 24/07/06): squared, so it's sqrt(4) = 2m, in 10Hz the vel = 20m/s ~ 72km/h -# If your scenario is different, may need adjust this TRUNCATED to 80-120km/h vel. -TRUNCATED_DIST = 4 - - -def seflowLoss(res_dict, timer=None): - pc0_label = res_dict['pc0_labels'] - pc1_label = res_dict['pc1_labels'] - - pc0 = res_dict['pc0'] - pc1 = res_dict['pc1'] - - est_flow = res_dict['est_flow'] - - pseudo_pc1from0 = pc0 + est_flow - - unique_labels = torch.unique(pc0_label) - pc0_dynamic = pc0[pc0_label > 0] - pc1_dynamic = pc1[pc1_label > 0] - # fpc1_dynamic = pseudo_pc1from0[pc0_label > 0] - # NOTE(Qingwen): since we set THREADS_PER_BLOCK is 256 - have_dynamic_cluster = (pc0_dynamic.shape[0] > 256) & (pc1_dynamic.shape[0] > 256) - - # first item loss: chamfer distance - # timer[5][1].start("MyCUDAChamferDis") - # raw: pc0 to pc1, est: pseudo_pc1from0 to pc1, idx means the nearest index - est_dist0, est_dist1, _, _ = MyCUDAChamferDis.disid_res(pseudo_pc1from0, pc1) - raw_dist0, raw_dist1, raw_idx0, _ = MyCUDAChamferDis.disid_res(pc0, pc1) - chamfer_dis = torch.mean(est_dist0[est_dist0 <= TRUNCATED_DIST]) + torch.mean(est_dist1[est_dist1 <= TRUNCATED_DIST]) - # timer[5][1].stop() - - # second item loss: dynamic chamfer distance - # timer[5][2].start("DynamicChamferDistance") - dynamic_chamfer_dis = torch.tensor(0.0, device=est_flow.device) - if have_dynamic_cluster: - dynamic_chamfer_dis += MyCUDAChamferDis(pseudo_pc1from0[pc0_label>0], pc1_dynamic, truncate_dist=TRUNCATED_DIST) - # timer[5][2].stop() - - # third item loss: exclude static points' flow - # NOTE(Qingwen): add in the later part on label==0 - static_cluster_loss = torch.tensor(0.0, device=est_flow.device) - - # fourth item loss: same label points' flow should be the same - # timer[5][3].start("SameClusterLoss") - moved_cluster_loss = torch.tensor(0.0, device=est_flow.device) - moved_cluster_norms = torch.tensor([], device=est_flow.device) - for label in unique_labels: - mask = pc0_label == label - if label == 0: - # Eq. 6 in the paper - static_cluster_loss += torch.linalg.vector_norm(est_flow[mask, :], dim=-1).mean() - elif label > 0 and have_dynamic_cluster: - cluster_id_flow = est_flow[mask, :] - cluster_nnd = raw_dist0[mask] - if cluster_nnd.shape[0] <= 0: - continue - - # Eq. 8 in the paper - sorted_idxs = torch.argsort(cluster_nnd, descending=True) - nearby_label = pc1_label[raw_idx0[mask][sorted_idxs]] # nonzero means dynamic in label - non_zero_valid_indices = torch.nonzero(nearby_label > 0) - if non_zero_valid_indices.shape[0] <= 0: - continue - max_idx = sorted_idxs[non_zero_valid_indices.squeeze(1)[0]] - - # Eq. 9 in the paper - max_flow = pc1[raw_idx0[mask][max_idx]] - pc0[mask][max_idx] - - # Eq. 10 in the paper - moved_cluster_norms = torch.cat((moved_cluster_norms, torch.linalg.vector_norm((cluster_id_flow - max_flow), dim=-1))) - - if moved_cluster_norms.shape[0] > 0: - moved_cluster_loss = moved_cluster_norms.mean() # Eq. 11 in the paper - elif have_dynamic_cluster: - moved_cluster_loss = torch.mean(raw_dist0[raw_dist0 <= TRUNCATED_DIST]) + torch.mean(raw_dist1[raw_dist1 <= TRUNCATED_DIST]) - # timer[5][3].stop() - - res_loss = { - 'chamfer_dis': chamfer_dis, - 'dynamic_chamfer_dis': dynamic_chamfer_dis, - 'static_flow_loss': static_cluster_loss, - 'cluster_based_pc0pc1': moved_cluster_loss, - } - return res_loss - -def deflowLoss(res_dict): - pred = res_dict['est_flow'] - gt = res_dict['gt_flow'] - - mask_no_nan = (~gt.isnan() & ~pred.isnan() & ~gt.isinf() & ~pred.isinf()) - - pred = pred[mask_no_nan].reshape(-1, 3) - gt = gt[mask_no_nan].reshape(-1, 3) - - speed = gt.norm(dim=1, p=2) / 0.1 - # pts_loss = torch.norm(pred - gt, dim=1, p=2) - pts_loss = torch.linalg.vector_norm(pred - gt, dim=-1) - - weight_loss = 0.0 - speed_0_4 = pts_loss[speed < 0.4].mean() - speed_mid = pts_loss[(speed >= 0.4) & (speed <= 1.0)].mean() - speed_1_0 = pts_loss[speed > 1.0].mean() - if ~speed_1_0.isnan(): - weight_loss += speed_1_0 - if ~speed_0_4.isnan(): - weight_loss += speed_0_4 - if ~speed_mid.isnan(): - weight_loss += speed_mid - return {'loss': weight_loss} - -# ref from zeroflow loss class FastFlow3DDistillationLoss() -def zeroflowLoss(res_dict): - pred = res_dict['est_flow'] - gt = res_dict['gt_flow'] - mask_no_nan = (~gt.isnan() & ~pred.isnan() & ~gt.isinf() & ~pred.isinf()) - - pred = pred[mask_no_nan].reshape(-1, 3) - gt = gt[mask_no_nan].reshape(-1, 3) - - error = torch.linalg.vector_norm(pred - gt, dim=-1) - # gt_speed = torch.norm(gt, dim=1, p=2) * 10.0 - gt_speed = torch.linalg.vector_norm(gt, dim=-1) * 10.0 - - mins = torch.ones_like(gt_speed) * 0.1 - maxs = torch.ones_like(gt_speed) - importance_scale = torch.max(mins, torch.min(1.8 * gt_speed - 0.8, maxs)) - # error = torch.norm(pred - gt, dim=1, p=2) * importance_scale - error = error * importance_scale - return {'loss': error.mean()} - -# ref from zeroflow loss class FastFlow3DSupervisedLoss() -def ff3dLoss(res_dict): - pred = res_dict['est_flow'] - gt = res_dict['gt_flow'] - classes = res_dict['gt_classes'] - # error = torch.norm(pred - gt, dim=1, p=2) - error = torch.linalg.vector_norm(pred - gt, dim=-1) - is_foreground_class = (classes > 0) # 0 is background, ref: FOREGROUND_BACKGROUND_BREAKDOWN - background_scalar = is_foreground_class.float() * 0.9 + 0.1 - error = error * background_scalar - return {'loss': error.mean()} diff --git a/src/lossfuncs/__init__.py b/src/lossfuncs/__init__.py new file mode 100644 index 0000000..7bf446b --- /dev/null +++ b/src/lossfuncs/__init__.py @@ -0,0 +1,19 @@ +""" +# Created: 2025-08-07 20:12 +# Copyright (C) 2025-now, RPL, KTH Royal Institute of Technology +# Author: Qingwen Zhang (https://kin-zhang.github.io/) +# +# This file is part of +# * OpenSceneFlow (https://github.com/KTH-RPL/OpenSceneFlow) +# * HiMo (https://kin-zhang.github.io/HiMo) +# +# If you find this repo helpful, please cite the respective publication as +# listed on the above website. +# +""" + + + +from .selfsupervise import * +from .supervise import * + diff --git a/src/lossfuncs/selfsupervise.py b/src/lossfuncs/selfsupervise.py new file mode 100644 index 0000000..dccc923 --- /dev/null +++ b/src/lossfuncs/selfsupervise.py @@ -0,0 +1,190 @@ +""" +# Created: 2023-07-17 00:00 +# Updated: 2025-08-07 00:01 +# Copyright (C) 2023-now, RPL, KTH Royal Institute of Technology +# Author: Qingwen Zhang (https://kin-zhang.github.io/) +# +# This file is part of +# * SeFlow (https://github.com/KTH-RPL/SeFlow) +# * HiMo (https://kin-zhang.github.io/HiMo) +# +# If you find this repo helpful, please cite the respective publication as +# listed on the above website. +# +# Description: Define the self-supervised (without GT) loss function for training. +# +""" +import torch +from assets.cuda.chamfer3D import nnChamferDis +MyCUDAChamferDis = nnChamferDis() + +# NOTE(Qingwen 24/07/06): squared, so it's sqrt(4) = 2m, in 10Hz the vel = 20m/s ~ 72km/h +# If your scenario is different, may need adjust this TRUNCATED to 80-120km/h vel. +TRUNCATED_DIST = 4 + +def seflowppLoss(res_dict, timer=None): + pch1_label = res_dict['pch1_labels'] + pc0_label = res_dict['pc0_labels'] + pc1_label = res_dict['pc1_labels'] + + pch1 = res_dict['pch1'] + pc0 = res_dict['pc0'] + pc1 = res_dict['pc1'] + + est_flow = res_dict['est_flow'] + + pseudo_pc1from0 = pc0 + est_flow + pseduo_pch1from0 = pc0 - est_flow + + unique_labels = torch.unique(pc0_label) + pc0_dynamic = pc0[pc0_label > 0] + pc1_dynamic = pc1[pc1_label > 0] + + # fpc1_dynamic = pseudo_pc1from0[pc0_label > 0] + # NOTE(Qingwen): since we set THREADS_PER_BLOCK is 256 + have_dynamic_cluster = (pc0_dynamic.shape[0] > 256) & (pc1_dynamic.shape[0] > 256) + + # first item loss: chamfer distance + # timer[5][1].start("MyCUDAChamferDis") + chamfer_dis = MyCUDAChamferDis(pseudo_pc1from0, pc1, truncate_dist=TRUNCATED_DIST) + MyCUDAChamferDis(pseduo_pch1from0, pch1, truncate_dist=TRUNCATED_DIST) + # timer[5][1].stop() + + # second item loss: dynamic chamfer distance + # timer[5][2].start("DynamicChamferDistance") + dynamic_chamfer_dis = torch.tensor(0.0, device=est_flow.device) + if have_dynamic_cluster: + dynamic_chamfer_dis += MyCUDAChamferDis(pseudo_pc1from0[pc0_label > 0], pc1_dynamic, truncate_dist=TRUNCATED_DIST) + if pch1[pch1_label > 0].shape[0] > 256: + dynamic_chamfer_dis += MyCUDAChamferDis(pseduo_pch1from0[pc0_label > 0], pch1[pch1_label > 0], truncate_dist=TRUNCATED_DIST) + # timer[5][2].stop() + + # third item loss: exclude static points' flow + # NOTE(Qingwen): add in the later part on label==0 + static_cluster_loss = torch.tensor(0.0, device=est_flow.device) + + # fourth item loss: same label points' flow should be the same + # timer[5][3].start("SameClusterLoss") + # raw: pc0 to pc1, est: pseudo_pc1from0 to pc1, idx means the nearest index + raw_dist0, raw_dist1, raw_idx0, _ = MyCUDAChamferDis.disid_res(pc0, pc1) + moved_cluster_loss = torch.tensor(0.0, device=est_flow.device) + moved_cluster_norms = torch.tensor([], device=est_flow.device) + for label in unique_labels: + mask = pc0_label == label + if label == 0: + # Eq. 6 in the SeFlow paper + static_cluster_loss += torch.linalg.vector_norm(est_flow[mask, :], dim=-1).mean() + # NOTE(Qingwen) 2025-04-23: label=1 is dynamic but no cluster id satisfied + elif label > 1 and have_dynamic_cluster: + cluster_id_flow = est_flow[mask, :] + cluster_nnd = raw_dist0[mask] + if cluster_nnd.shape[0] <= 0: + continue + + # Eq. 8 in the SeFlow paper + sorted_idxs = torch.argsort(cluster_nnd, descending=True) + nearby_label = pc1_label[raw_idx0[mask][sorted_idxs]] # nonzero means dynamic in label + non_zero_valid_indices = torch.nonzero(nearby_label > 0) + if non_zero_valid_indices.shape[0] <= 0: + continue + max_idx = sorted_idxs[non_zero_valid_indices.squeeze(1)[0]] + + # Eq. 9 in the SeFlow paper + max_flow = pc1[raw_idx0[mask][max_idx]] - pc0[mask][max_idx] + + # Eq. 10 in the SeFlow paper + moved_cluster_norms = torch.cat((moved_cluster_norms, torch.linalg.vector_norm((cluster_id_flow - max_flow), dim=-1))) + + if moved_cluster_norms.shape[0] > 0: + moved_cluster_loss = moved_cluster_norms.mean() # Eq. 11 in the SeFlow paper + elif have_dynamic_cluster: + moved_cluster_loss = torch.mean(raw_dist0[raw_dist0 <= TRUNCATED_DIST]) + torch.mean(raw_dist1[raw_dist1 <= TRUNCATED_DIST]) + # timer[5][3].stop() + + res_loss = { + 'chamfer_dis': chamfer_dis / 2.0, + 'dynamic_chamfer_dis': dynamic_chamfer_dis / 2.0, + 'static_flow_loss': static_cluster_loss, + 'cluster_based_pc0pc1': moved_cluster_loss, + } + return res_loss + +def seflowLoss(res_dict, timer=None): + pc0_label = res_dict['pc0_labels'] + pc1_label = res_dict['pc1_labels'] + + pc0 = res_dict['pc0'] + pc1 = res_dict['pc1'] + + est_flow = res_dict['est_flow'] + + pseudo_pc1from0 = pc0 + est_flow + + unique_labels = torch.unique(pc0_label) + pc0_dynamic = pc0[pc0_label > 0] + pc1_dynamic = pc1[pc1_label > 0] + # fpc1_dynamic = pseudo_pc1from0[pc0_label > 0] + # NOTE(Qingwen): since we set THREADS_PER_BLOCK is 256 + have_dynamic_cluster = (pc0_dynamic.shape[0] > 256) & (pc1_dynamic.shape[0] > 256) + + # first item loss: chamfer distance + # timer[5][1].start("MyCUDAChamferDis") + # raw: pc0 to pc1, est: pseudo_pc1from0 to pc1, idx means the nearest index + est_dist0, est_dist1, _, _ = MyCUDAChamferDis.disid_res(pseudo_pc1from0, pc1) + raw_dist0, raw_dist1, raw_idx0, _ = MyCUDAChamferDis.disid_res(pc0, pc1) + chamfer_dis = torch.mean(est_dist0[est_dist0 <= TRUNCATED_DIST]) + torch.mean(est_dist1[est_dist1 <= TRUNCATED_DIST]) + # timer[5][1].stop() + + # second item loss: dynamic chamfer distance + # timer[5][2].start("DynamicChamferDistance") + dynamic_chamfer_dis = torch.tensor(0.0, device=est_flow.device) + if have_dynamic_cluster: + dynamic_chamfer_dis += MyCUDAChamferDis(pseudo_pc1from0[pc0_label>0], pc1_dynamic, truncate_dist=TRUNCATED_DIST) + # timer[5][2].stop() + + # third item loss: exclude static points' flow + # NOTE(Qingwen): add in the later part on label==0 + static_cluster_loss = torch.tensor(0.0, device=est_flow.device) + + # fourth item loss: same label points' flow should be the same + # timer[5][3].start("SameClusterLoss") + moved_cluster_loss = torch.tensor(0.0, device=est_flow.device) + moved_cluster_norms = torch.tensor([], device=est_flow.device) + for label in unique_labels: + mask = pc0_label == label + if label == 0: + # Eq. 6 in the paper + static_cluster_loss += torch.linalg.vector_norm(est_flow[mask, :], dim=-1).mean() + # NOTE(Qingwen) 2025-04-23: label=1 is dynamic but no cluster id satisfied + elif label > 1 and have_dynamic_cluster: + cluster_id_flow = est_flow[mask, :] + cluster_nnd = raw_dist0[mask] + if cluster_nnd.shape[0] <= 0: + continue + + # Eq. 8 in the paper + sorted_idxs = torch.argsort(cluster_nnd, descending=True) + nearby_label = pc1_label[raw_idx0[mask][sorted_idxs]] # nonzero means dynamic in label + non_zero_valid_indices = torch.nonzero(nearby_label > 0) + if non_zero_valid_indices.shape[0] <= 0: + continue + max_idx = sorted_idxs[non_zero_valid_indices.squeeze(1)[0]] + + # Eq. 9 in the paper + max_flow = pc1[raw_idx0[mask][max_idx]] - pc0[mask][max_idx] + + # Eq. 10 in the paper + moved_cluster_norms = torch.cat((moved_cluster_norms, torch.linalg.vector_norm((cluster_id_flow - max_flow), dim=-1))) + + if moved_cluster_norms.shape[0] > 0: + moved_cluster_loss = moved_cluster_norms.mean() # Eq. 11 in the paper + elif have_dynamic_cluster: + moved_cluster_loss = torch.mean(raw_dist0[raw_dist0 <= TRUNCATED_DIST]) + torch.mean(raw_dist1[raw_dist1 <= TRUNCATED_DIST]) + # timer[5][3].stop() + + res_loss = { + 'chamfer_dis': chamfer_dis, + 'dynamic_chamfer_dis': dynamic_chamfer_dis, + 'static_flow_loss': static_cluster_loss, + 'cluster_based_pc0pc1': moved_cluster_loss, + } + return res_loss diff --git a/src/lossfuncs/supervise.py b/src/lossfuncs/supervise.py new file mode 100644 index 0000000..b72e7a1 --- /dev/null +++ b/src/lossfuncs/supervise.py @@ -0,0 +1,107 @@ +""" +# Created: 2023-07-17 00:00 +# Copyright (C) 2023-now, RPL, KTH Royal Institute of Technology +# Author: Qingwen Zhang (https://kin-zhang.github.io/) +# +# This file is part of +# * OpenSceneFlow (https://github.com/KTH-RPL/OpenSceneFlow) +# If you find this repo helpful, please cite the respective publication as +# listed on the above website. +# +# Description: Define the supervised (needed GT) loss function for training. +# +""" +import torch +import numpy as np +import os, sys +BASE_DIR = os.path.abspath(os.path.join( os.path.dirname( __file__ ), '../..' )) +sys.path.append(BASE_DIR) + +def deflowLoss(res_dict): + pred = res_dict['est_flow'] + gt = res_dict['gt_flow'] + + mask_no_nan = (~gt.isnan() & ~pred.isnan() & ~gt.isinf() & ~pred.isinf()) + + pred = pred[mask_no_nan].reshape(-1, 3) + gt = gt[mask_no_nan].reshape(-1, 3) + + speed = gt.norm(dim=1, p=2) / 0.1 + pts_loss = torch.linalg.vector_norm(pred - gt, dim=-1) + + weight_loss = 0.0 + for loss_ in [pts_loss[speed < 0.4].mean(), + pts_loss[(speed >= 0.4) & (speed <= 1.0)].mean(), + pts_loss[speed > 1.0].mean()]: + weight_loss += torch.nan_to_num(loss_, nan=0.0) + + return {'loss': weight_loss} + +# designed from MambaFlow: https://github.com/SCNU-RISLAB/MambaFlow +def mambaflowLoss(res_dict): + pred = res_dict['est_flow'] + gt = res_dict['gt_flow'] + mask_no_nan = (~gt.isnan() & ~pred.isnan() & ~gt.isinf() & ~pred.isinf()) + pred = pred[mask_no_nan].reshape(-1, 3) + gt = gt[mask_no_nan].reshape(-1, 3) + + speed = gt.norm(dim=1, p=2) / 0.1 + pts_loss = torch.linalg.vector_norm(pred - gt, dim=-1) + + velocities = speed.cpu().numpy() + + # 计算直方图,返回每个区间的计数和区间边界 + counts, bin_edges = np.histogram(velocities, bins=100, density=False) + + # 计算每个区间的点数占总点数的比例 + total_points = len(velocities) + proportions = counts / total_points + + # 计算每个区间的中心位置,用于绘图 + bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2 + + # 设置占比阈值 + proportion_threshold = 0.01 # 可以根据需要调整这个值 + + # 找出第一个占比小于阈值的柱子 + first_below_threshold = next((i for i, prop in enumerate(proportions) if prop < proportion_threshold), None) + turning_speed = bin_centers[first_below_threshold] + + weight_loss = 0.0 + for loss_ in [pts_loss[speed < turning_speed].mean(), + pts_loss[(speed >= turning_speed) & (speed <= 2)].mean(), + pts_loss[speed > 2].mean()]: + weight_loss += torch.nan_to_num(loss_, nan=0.0) + return {'loss': weight_loss} + +# ref from zeroflow loss class FastFlow3DDistillationLoss() +def zeroflowLoss(res_dict): + pred = res_dict['est_flow'] + gt = res_dict['gt_flow'] + mask_no_nan = (~gt.isnan() & ~pred.isnan() & ~gt.isinf() & ~pred.isinf()) + + pred = pred[mask_no_nan].reshape(-1, 3) + gt = gt[mask_no_nan].reshape(-1, 3) + + error = torch.linalg.vector_norm(pred - gt, dim=-1) + # gt_speed = torch.norm(gt, dim=1, p=2) * 10.0 + gt_speed = torch.linalg.vector_norm(gt, dim=-1) * 10.0 + + mins = torch.ones_like(gt_speed) * 0.1 + maxs = torch.ones_like(gt_speed) + importance_scale = torch.max(mins, torch.min(1.8 * gt_speed - 0.8, maxs)) + # error = torch.norm(pred - gt, dim=1, p=2) * importance_scale + error = error * importance_scale + return {'loss': error.mean()} + +# ref from zeroflow loss class FastFlow3DSupervisedLoss() +def ff3dLoss(res_dict): + pred = res_dict['est_flow'] + gt = res_dict['gt_flow'] + classes = res_dict['gt_classes'] + # error = torch.norm(pred - gt, dim=1, p=2) + error = torch.linalg.vector_norm(pred - gt, dim=-1) + is_foreground_class = (classes > 0) # 0 is background, ref: FOREGROUND_BACKGROUND_BREAKDOWN + background_scalar = is_foreground_class.float() * 0.9 + 0.1 + error = error * background_scalar + return {'loss': error.mean()} diff --git a/src/models/__init__.py b/src/models/__init__.py index 410b773..5f9d9cf 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -10,7 +10,7 @@ import warnings warnings.simplefilter(action="ignore", category=FutureWarning) -from .deflow import DeFlow +from .deflow import DeFlow, DeFlowPP from .fastflow3d import FastFlow3D from .nsfp import NSFP diff --git a/src/models/basic/unet.py b/src/models/basic/unet.py index f7f651d..3545324 100644 --- a/src/models/basic/unet.py +++ b/src/models/basic/unet.py @@ -1,7 +1,5 @@ import torch import torch.nn as nn - -from typing import Tuple from . import ConvWithNorms @@ -98,4 +96,147 @@ def forward(self, pc0_B: torch.Tensor, U = self.decoder_step3(T, Bstar) V = self.decoder_step4(U) - return V \ No newline at end of file + return V + +class UNetThreeFrame(nn.Module): + """ + Standard UNet with a few modifications: + - Uses Bilinear interpolation instead of transposed convolutions + """ + + def __init__(self) -> None: + super().__init__() + + self.encoder_step_1 = nn.Sequential(ConvWithNorms(32, 64, 3, 2, 1), + ConvWithNorms(64, 64, 3, 1, 1), + ConvWithNorms(64, 64, 3, 1, 1), + ConvWithNorms(64, 64, 3, 1, 1)) + self.encoder_step_2 = nn.Sequential(ConvWithNorms(64, 128, 3, 2, 1), + ConvWithNorms(128, 128, 3, 1, 1), + ConvWithNorms(128, 128, 3, 1, 1), + ConvWithNorms(128, 128, 3, 1, 1), + ConvWithNorms(128, 128, 3, 1, 1), + ConvWithNorms(128, 128, 3, 1, 1)) + self.encoder_step_3 = nn.Sequential(ConvWithNorms(128, 256, 3, 2, 1), + ConvWithNorms(256, 256, 3, 1, 1), + ConvWithNorms(256, 256, 3, 1, 1), + ConvWithNorms(256, 256, 3, 1, 1), + ConvWithNorms(256, 256, 3, 1, 1), + ConvWithNorms(256, 256, 3, 1, 1)) + self.decoder_step1 = UpsampleSkip(768, 384, 384) + self.decoder_step2 = UpsampleSkip(384, 192, 192) + self.decoder_step3 = UpsampleSkip(192, 96, 96) + self.decoder_step4 = nn.Conv2d(96, 96, 3, 1, 1) + + def forward(self, pcb0_B: torch.Tensor, pc0_B: torch.Tensor, + pc1_B: torch.Tensor) -> torch.Tensor: + + expected_channels = 32 + assert pc0_B.shape[ + 1] == expected_channels, f"Expected {expected_channels} channels, got {pc0_B.shape[1]}" + assert pc1_B.shape[ + 1] == expected_channels, f"Expected {expected_channels} channels, got {pc1_B.shape[1]}" + + pcb0_F = self.encoder_step_1(pcb0_B) + pcb0_L = self.encoder_step_2(pcb0_F) + pcb0_R = self.encoder_step_3(pcb0_L) + + pc0_F = self.encoder_step_1(pc0_B) + pc0_L = self.encoder_step_2(pc0_F) + pc0_R = self.encoder_step_3(pc0_L) + + pc1_F = self.encoder_step_1(pc1_B) + pc1_L = self.encoder_step_2(pc1_F) + pc1_R = self.encoder_step_3(pc1_L) + + Rstar = torch.cat([pcb0_R, pc0_R, pc1_R], + dim=1) # torch.Size([1, 512, 64, 64]), torch.Size([1, 768, 64, 64]) + Lstar = torch.cat([pcb0_L, pc0_L, pc1_L], + dim=1) # torch.Size([1, 256, 128, 128]), torch.Size([1, 384, 128, 128]) + Fstar = torch.cat([pcb0_F, pc0_F, pc1_F], + dim=1) # torch.Size([1, 128, 256, 256]), torch.Size([1, 192, 256, 256]) + Bstar = torch.cat([pcb0_B, pc0_B, pc1_B], + dim=1) # torch.Size([1, 64, 512, 512]), torch.Size([1, 96, 512, 512]) + + S = self.decoder_step1(Rstar, Lstar) + T = self.decoder_step2(S, Fstar) + U = self.decoder_step3(T, Bstar) + V = self.decoder_step4(U) + + return V + +class ZeroFlowUNetXL(nn.Module): + """ + Standard UNet with a few modifications: + - Uses Bilinear interpolation instead of transposed convolutions + """ + + def __init__(self) -> None: + super().__init__() + + self.encoder_step_1 = nn.Sequential(ConvWithNorms(64, 128, 3, 2, 1), + ConvWithNorms(128, 128, 3, 1, 1), + ConvWithNorms(128, 128, 3, 1, 1), + ConvWithNorms(128, 128, 3, 1, 1)) + self.encoder_step_2 = nn.Sequential(ConvWithNorms(128, 256, 3, 2, 1), + ConvWithNorms(256, 256, 3, 1, 1), + ConvWithNorms(256, 256, 3, 1, 1), + ConvWithNorms(256, 256, 3, 1, 1), + ConvWithNorms(256, 256, 3, 1, 1), + ConvWithNorms(256, 256, 3, 1, 1)) + self.encoder_step_3 = nn.Sequential(ConvWithNorms(256, 512, 3, 2, 1), + ConvWithNorms(512, 512, 3, 1, 1), + ConvWithNorms(512, 512, 3, 1, 1), + ConvWithNorms(512, 512, 3, 1, 1), + ConvWithNorms(512, 512, 3, 1, 1), + ConvWithNorms(512, 512, 3, 1, 1)) + self.encoder_step_4 = nn.Sequential(ConvWithNorms(512, 1024, 3, 2, 1), + ConvWithNorms(1024, 1024, 3, 1, 1), + ConvWithNorms(1024, 1024, 3, 1, 1), + ConvWithNorms(1024, 1024, 3, 1, 1), + ConvWithNorms(1024, 1024, 3, 1, 1), + ConvWithNorms(1024, 1024, 3, 1, 1)) + + self.decoder_step1 = UpsampleSkip(2048, 1024, 1024) + self.decoder_step2 = UpsampleSkip(1024, 512, 512) + self.decoder_step3 = UpsampleSkip(512, 256, 256) + self.decoder_step4 = UpsampleSkip(256, 128, 128) + self.decoder_step5 = nn.Conv2d(128, 128, 3, 1, 1) + + def forward(self, pc0_B: torch.Tensor, + pc1_B: torch.Tensor) -> torch.Tensor: + + expected_channels = 64 + assert pc0_B.shape[ + 1] == expected_channels, f"Expected {expected_channels} channels, got {pc0_B.shape[1]}" + assert pc1_B.shape[ + 1] == expected_channels, f"Expected {expected_channels} channels, got {pc1_B.shape[1]}" + + pc0_F = self.encoder_step_1(pc0_B) + pc0_L = self.encoder_step_2(pc0_F) + pc0_R = self.encoder_step_3(pc0_L) + pc0_T = self.encoder_step_4(pc0_R) + + pc1_F = self.encoder_step_1(pc1_B) + pc1_L = self.encoder_step_2(pc1_F) + pc1_R = self.encoder_step_3(pc1_L) + pc1_T = self.encoder_step_4(pc1_R) + + Tstar = torch.cat([pc0_T, pc1_T], + dim=1) # torch.Size([1, 2048, 32, 32]) + Rstar = torch.cat([pc0_R, pc1_R], + dim=1) # torch.Size([1, 1024, 64, 64]) + Lstar = torch.cat([pc0_L, pc1_L], + dim=1) # torch.Size([1, 512, 128, 128]) + Fstar = torch.cat([pc0_F, pc1_F], + dim=1) # torch.Size([1, 256, 256, 256]) + Bstar = torch.cat([pc0_B, pc1_B], + dim=1) # torch.Size([1, 128, 512, 512]) + + S = self.decoder_step1(Tstar, Rstar) + T = self.decoder_step2(S, Lstar) + U = self.decoder_step3(T, Fstar) + V = self.decoder_step4(U, Bstar) + W = self.decoder_step5(V) + + return W \ No newline at end of file diff --git a/src/models/deflow.py b/src/models/deflow.py index d624690..e217801 100644 --- a/src/models/deflow.py +++ b/src/models/deflow.py @@ -12,10 +12,10 @@ import torch.nn as nn import dztimer, torch -from .basic.unet import FastFlow3DUNet +from .basic.unet import FastFlow3DUNet, UNetThreeFrame from .basic.encoder import DynamicEmbedder from .basic.decoder import LinearDecoder, ConvGRUDecoder -from .basic import cal_pose0to1, BaseModel +from .basic import wrap_batch_pcs, BaseModel class DeFlow(BaseModel): def __init__(self, voxel_size = [0.2, 0.2, 6], @@ -45,29 +45,9 @@ def forward(self, batch): output: the predicted flow, pose_flow, and the valid point index of pc0 """ self.timer[0].start("Data Preprocess") - batch_sizes = len(batch["pose0"]) - - pose_flows = [] - transform_pc0s = [] - for batch_id in range(batch_sizes): - selected_pc0 = batch["pc0"][batch_id] - self.timer[0][0].start("pose") - with torch.no_grad(): - if 'ego_motion' in batch: - pose_0to1 = batch['ego_motion'][batch_id] - else: - pose_0to1 = cal_pose0to1(batch["pose0"][batch_id], batch["pose1"][batch_id]) - self.timer[0][0].stop() - - self.timer[0][1].start("transform") - # transform selected_pc0 to pc1 - transform_pc0 = selected_pc0 @ pose_0to1[:3, :3].T + pose_0to1[:3, 3] - self.timer[0][1].stop() - pose_flows.append(transform_pc0 - selected_pc0) - transform_pc0s.append(transform_pc0) - - pc0s = torch.stack(transform_pc0s, dim=0) - pc1s = batch["pc1"] + pcs_dict = wrap_batch_pcs(batch, num_frames=2) + pc0s = pcs_dict['pc0s'] + pc1s = pcs_dict['pc1s'] self.timer[0].stop() self.timer[1].start("Voxelization") @@ -94,7 +74,7 @@ def forward(self, batch): model_res = { "flow": flows, - 'pose_flow': pose_flows, + 'pose_flow': pcs_dict['pose_flows'], "pc0_valid_point_idxes": pc0_valid_point_idxes, "pc0_points_lst": pc0_points_lst, @@ -104,3 +84,75 @@ def forward(self, batch): "num_occupied_voxels": [grid_flow_pseudoimage.size(-1)*grid_flow_pseudoimage.size(-2)] } return model_res + + + +class DeFlowPP(BaseModel): + def __init__(self, voxel_size = [0.2, 0.2, 6], + point_cloud_range = [-51.2, -51.2, -3, 51.2, 51.2, 3], + grid_feature_size = [512, 512], + decoder_option = "gru", + num_iters = 2, + num_frames = 3): + super().__init__() + self.embedder = DynamicEmbedder(voxel_size=voxel_size, + pseudo_image_dims=grid_feature_size, + point_cloud_range=point_cloud_range, + feat_channels=32) + + self.backbone = UNetThreeFrame() + if decoder_option == "gru": + self.head = ConvGRUDecoder(pseudoimage_channels=96, num_iters = num_iters) + else: + self.head = LinearDecoder() + + self.num_frames = num_frames + assert self.num_frames == 3, "DeFlowPP only supports num_frames = 3" + + self.timer = dztimer.Timing() + self.timer.start("Total") + + def forward(self, batch): + """ + input: using the batch from dataloader, which is a dict + Detail: [pc0, pc1, pose0, pose1] + output: the predicted flow, pose_flow, and the valid point index of pc0 + """ + self.timer[0].start("Data Preprocess") + pcs_dict = wrap_batch_pcs(batch, num_frames=self.num_frames) + pc0s = pcs_dict['pc0s'] + pc1s = pcs_dict['pc1s'] + pch1s = pcs_dict['pch1s'] + self.timer[0].stop() + + self.timer[1].start("Voxelization") + pch1_before_pseudoimages, pch1_voxel_infos_lst = self.embedder(pch1s) + pc0_before_pseudoimages, pc0_voxel_infos_lst = self.embedder(pc0s) + pc1_before_pseudoimages, pc1_voxel_infos_lst = self.embedder(pc1s) + self.timer[1].stop() + + self.timer[2].start("Encoder") + grid_flow_pseudoimage = self.backbone(pch1_before_pseudoimages, pc0_before_pseudoimages, + pc1_before_pseudoimages) + self.timer[2].stop() + + self.timer[3].start("Decoder") + flows = self.head( + torch.cat((pch1_before_pseudoimages, pc0_before_pseudoimages, pc1_before_pseudoimages), + dim=1), grid_flow_pseudoimage, pc0_voxel_infos_lst) + self.timer[3].stop() + + model_res = { + "flow": flows, + 'pose_flow': pcs_dict['pose_flows'], + + "pc0_valid_point_idxes": [e["point_idxes"] for e in pc0_voxel_infos_lst], + "pc0_points_lst": [e["points"] for e in pc0_voxel_infos_lst], + + "pc1_valid_point_idxes": [e["point_idxes"] for e in pc1_voxel_infos_lst], + "pc1_points_lst": [e["points"] for e in pc1_voxel_infos_lst], + + 'pch1_valid_point_idxes': [e["point_idxes"] for e in pch1_voxel_infos_lst], + 'pch1_points_lst': [e["points"] for e in pch1_voxel_infos_lst], + } + return model_res \ No newline at end of file diff --git a/src/models/fastflow3d.py b/src/models/fastflow3d.py index e4d5774..f6a3d14 100644 --- a/src/models/fastflow3d.py +++ b/src/models/fastflow3d.py @@ -12,7 +12,7 @@ from .basic.unet import FastFlow3DUNet from .basic.encoder import DynamicEmbedder from .basic.decoder import LinearDecoder -from .basic import cal_pose0to1, BaseModel +from .basic import wrap_batch_pcs, BaseModel class FastFlow3D(BaseModel): @@ -68,31 +68,15 @@ def forward(self, batch, compute_symmetry_y=False): self.timer[0].start("Data Preprocess") - batch_sizes = len(batch["pose0"]) - - pose_flows = [] - transform_pc0s = [] - for batch_id in range(batch_sizes): - selected_pc0 = batch["pc0"][batch_id] - self.timer[0][0].start("pose") - pose_0to1 = cal_pose0to1(batch["pose0"][batch_id], batch["pose1"][batch_id]) - self.timer[0][0].stop() - - self.timer[0][1].start("transform") - # transform selected_pc0 to pc1 - transform_pc0 = selected_pc0 @ pose_0to1[:3, :3].T + pose_0to1[:3, 3] - self.timer[0][1].stop() - pose_flows.append(transform_pc0 - selected_pc0) - transform_pc0s.append(transform_pc0) - - pc0s = torch.stack(transform_pc0s, dim=0) - pc1s = batch["pc1"] + pcs_dict = wrap_batch_pcs(batch, num_frames=2) + pc0s = pcs_dict['pc0s'] + pc1s = pcs_dict['pc1s'] self.timer[0].stop() model_res = self._model_forward(pc0s, pc1s) ret_dict = model_res - ret_dict["pose_flow"] = pose_flows + ret_dict["pose_flow"] = pcs_dict['pose_flows'] if compute_cycle: # The warped pointcloud, original pointcloud should be the input to the model pc0_warped_pc1_points_lst = model_res["pc0_warped_pc1_points_lst"] diff --git a/src/models/ssf.py b/src/models/ssf.py index 8fb9799..1c0eeb6 100644 --- a/src/models/ssf.py +++ b/src/models/ssf.py @@ -16,7 +16,7 @@ from .basic.encoder import DynamicVoxelizer from .basic.ssf_module import DynamicScatterVFE from .basic.decoder import SimpleLinearDecoder -from .basic import cal_pose0to1, BaseModel +from .basic import wrap_batch_pcs, BaseModel class SSF(BaseModel): def __init__(self, voxel_size = [0.2, 0.2, 6], @@ -64,31 +64,11 @@ def forward(self, batch): output: the predicted flow, pose_flow, and the valid point index of pc0 """ self.timer[0].start("Data Preprocess") - batch_sizes = len(batch["pose0"]) - - pose_flows = [] - transform_pc0s = [] - for batch_id in range(batch_sizes): - selected_pc0 = batch["pc0"][batch_id] - self.timer[0][0].start("pose") - with torch.no_grad(): - if 'ego_motion' in batch: - pose_0to1 = batch['ego_motion'][batch_id] - else: - pose_0to1 = cal_pose0to1(batch["pose0"][batch_id], batch["pose1"][batch_id]) - self.timer[0][0].stop() - - self.timer[0][1].start("transform") - # transform selected_pc0 to pc1 - transform_pc0 = selected_pc0 @ pose_0to1[:3, :3].T + pose_0to1[:3, 3] - self.timer[0][1].stop() - pose_flows.append(transform_pc0 - selected_pc0) - transform_pc0s.append(transform_pc0) - - pc0s = torch.stack(transform_pc0s, dim=0) + pcs_dict = wrap_batch_pcs(batch, num_frames=2) + pc0s = pcs_dict['pc0s'] pc0s = torch.cat((pc0s, torch.ones((pc0s.size(0), pc0s.size(1),1)).to(pc0s.device) * 0.), dim=2) # indicator_pc0s = torch.ones((pc0s.size(0), pc0s.size(1),1), dtype=torch.int).to(pc0s.device) * 0 - pc1s = batch["pc1"] + pc1s = pcs_dict['pc1s'] pc1s = torch.cat((pc1s, torch.ones((pc1s.size(0), pc1s.size(1),1)).to(pc1s.device) * 1.), dim=2) # indicator_pc1s = torch.ones((pc1s.size(0), pc1s.size(1),1), dtype=torch.int).to(pc1s.device) * 1 pcs_concat = torch.cat((pc0s, pc1s), dim=1) @@ -144,11 +124,12 @@ def forward(self, batch): pc1_points_lst = [e["points"] for e in pc1_voxel_infos_lst] pc0_valid_point_idxes = [e["point_idxes"] for e in pc0_voxel_infos_lst] - pc1_valid_point_idxes = [e["point_idxes"] for e in pc1_voxel_infos_lst] + # since we concat in voxel_infos_dict + pc1_valid_point_idxes = [e["point_idxes"] - pc0s.shape[1] for e in pc1_voxel_infos_lst] model_res = { "flow": flows, - 'pose_flow': pose_flows, + 'pose_flow': pcs_dict['pose_flows'], "pc0_valid_point_idxes": pc0_valid_point_idxes, "pc0_points_lst": pc0_points_lst, diff --git a/src/runner.py b/src/runner.py index 98d5644..e15fd7a 100644 --- a/src/runner.py +++ b/src/runner.py @@ -106,7 +106,7 @@ def __init__(self, cfg, rank, world_size, mode): def _setup_dataloader(self): if self.mode in ['val', 'test', 'eval']: - dataset_path = self.cfg.dataset_path + f"/{self.cfg.av2_mode}" + dataset_path = self.cfg.dataset_path + f"/{self.cfg.data_mode}" is_eval_mode = True else: # 'save' dataset_path = self.cfg.dataset_path diff --git a/src/trainer.py b/src/trainer.py index 53954fa..e2eca7a 100644 --- a/src/trainer.py +++ b/src/trainer.py @@ -27,7 +27,7 @@ from src.utils import import_func from src.utils.mics import weights_init, zip_res from src.utils.av2_eval import write_output_file -from src.models.basic import cal_pose0to1 +from src.models.basic import cal_pose0to1, WarmupCosLR from src.utils.eval_metric import OfficialMetrics, evaluate_leaderboard, evaluate_leaderboard_v2, evaluate_ssf # debugging tools @@ -39,7 +39,25 @@ class ModelWrapper(LightningModule): def __init__(self, cfg, eval=False): super().__init__() - # set grid size + default_self_values = { + "batch_size": 1, + "lr": 2e-4, + "epochs": 3, + "loss_fn": 'deflowLoss', + "add_seloss": None, + "checkpoint": None, + "leaderboard_version": 2, + "supervised_flag": True, + "save_res": False, + "res_name": "default", + "num_frames": 2, + "optimizer": None, + "dataset_path": None, + "data_mode": None, + } + for key, default in default_self_values.items(): + setattr(self, key, cfg.get(key, default)) + if ('voxel_size' in cfg.model.target) and ('point_cloud_range' in cfg.model.target) and not eval and 'point_cloud_range' in cfg: OmegaConf.set_struct(cfg.model.target, True) with open_dict(cfg.model.target): @@ -54,45 +72,33 @@ def __init__(self, cfg, eval=False): abs(int((cfg.model.target.point_cloud_range[1] - cfg.model.target.point_cloud_range[4]) / cfg.model.target.voxel_size[1])), abs(int((cfg.model.target.point_cloud_range[2] - cfg.model.target.point_cloud_range[5]) / cfg.model.target.voxel_size[2]))] + # ---> model + self.point_cloud_range = cfg.model.target.point_cloud_range self.model = instantiate(cfg.model.target) self.model.apply(weights_init) - + if 'pretrained_weights' in cfg and cfg.pretrained_weights is not None: + missing_keys, unexpected_keys = self.model.load_from_checkpoint(cfg.pretrained_weights) + # print(f"Model: {self.model.__class__.__name__}, Number of Frames: {self.num_frames}") + + # ---> loss fn self.loss_fn = import_func("src.lossfuncs."+cfg.loss_fn) if 'loss_fn' in cfg else None - self.add_seloss = cfg.add_seloss if 'add_seloss' in cfg else None - self.cfg_loss_name = cfg.loss_fn if 'loss_fn' in cfg else None - - self.batch_size = int(cfg.batch_size) if 'batch_size' in cfg else 1 - self.lr = cfg.lr if 'lr' in cfg else None - self.lr_scheduler = cfg.lr_scheduler if 'lr_scheduler' in cfg else None - self.epochs = cfg.epochs if 'epochs' in cfg else None + self.cfg_loss_name = cfg.get("loss_fn", None) + # ---> evaluation metric self.metrics = OfficialMetrics() - self.load_checkpoint_path = cfg.checkpoint if 'checkpoint' in cfg else None - + # ---> inference mode + if self.save_res and self.data_mode in ['val', 'valid', 'test']: + self.save_res_path = Path(cfg.dataset_path).parent / "results" / cfg.output + os.makedirs(self.save_res_path, exist_ok=True) + print(f"We are in {cfg.data_mode}, results will be saved in: {self.save_res_path} with version: {self.leaderboard_version} format for online leaderboard.") - self.leaderboard_version = cfg.leaderboard_version if 'leaderboard_version' in cfg else 1 - # NOTE(Qingwen): since we have seflow version which is unsupervised, we need to set the flag to false. - self.supervised_flag = cfg.supervised_flag if 'supervised_flag' in cfg else True - self.save_res = False - if 'av2_mode' in cfg: - self.av2_mode = cfg.av2_mode - self.save_res = cfg.save_res if 'save_res' in cfg else False - - if self.save_res or self.av2_mode == 'test': - self.save_res_path = Path(cfg.dataset_path).parent / "results" / cfg.output - os.makedirs(self.save_res_path, exist_ok=True) - print(f"We are in {cfg.av2_mode}, results will be saved in: {self.save_res_path} with version: {self.leaderboard_version} format for online leaderboard.") - else: - self.av2_mode = None - if 'pretrained_weights' in cfg: - if cfg.pretrained_weights is not None: - self.model.load_from_checkpoint(cfg.pretrained_weights) - - self.dataset_path = cfg.dataset_path if 'dataset_path' in cfg else None - self.vis_name = cfg.res_name if 'res_name' in cfg else 'default' + # self.test_total_num = 0 + if self.data_mode in ['val', 'valid', 'test']: + print(cfg) self.save_hyperparameters() + # FIXME(Qingwen 2025-08-20): update the loss_calculation fn alone to make all things pretty here.... def training_step(self, batch, batch_idx): self.model.timer[4].start("One Scan in model") res_dict = self.model(batch) @@ -102,7 +108,7 @@ def training_step(self, batch, batch_idx): # compute loss total_loss = 0.0 - if self.cfg_loss_name in ['seflowLoss']: + if self.cfg_loss_name in ['seflowLoss', 'seflowppLoss']: loss_items, weights = zip(*[(key, weight) for key, weight in self.add_seloss.items()]) loss_logger = {'chamfer_dis': 0.0, 'dynamic_chamfer_dis': 0.0, 'static_flow_loss': 0.0, 'cluster_based_pc0pc1': 0.0} else: @@ -131,11 +137,15 @@ def training_step(self, batch, batch_idx): if 'pc0_dynamic' in batch: dict2loss['pc0_labels'] = batch['pc0_dynamic'][batch_id][pc0_valid_from_pc2res] dict2loss['pc1_labels'] = batch['pc1_dynamic'][batch_id][pc1_valid_from_pc2res] + if 'pch1_dynamic' in batch and 'pch1_valid_point_idxes' in res_dict: + dict2loss['pch1_labels'] = batch['pch1_dynamic'][batch_id][res_dict['pch1_valid_point_idxes'][batch_id]] # different methods may don't have this in the res_dict if 'pc0_points_lst' in res_dict and 'pc1_points_lst' in res_dict: dict2loss['pc0'] = pc0_points_lst[batch_id] dict2loss['pc1'] = pc1_points_lst[batch_id] + if 'pch1_points_lst' in res_dict: + dict2loss['pch1'] = res_dict['pch1_points_lst'][batch_id] res_loss = self.loss_fn(dict2loss) for i, loss_name in enumerate(loss_items): @@ -144,7 +154,7 @@ def training_step(self, batch, batch_idx): loss_logger[key] += res_loss[key] self.log("trainer/loss", total_loss/batch_sizes, sync_dist=True, batch_size=self.batch_size, prog_bar=True) - if self.add_seloss is not None and self.cfg_loss_name in ['seflowLoss']: + if self.add_seloss is not None and self.cfg_loss_name in ['seflowLoss', 'seflowppLoss']: for key in loss_logger: self.log(f"trainer/{key}", loss_logger[key]/batch_sizes, sync_dist=True, batch_size=self.batch_size) self.model.timer[5].stop() @@ -173,14 +183,22 @@ def train_validation_step_(self, batch, res_dict): pass def configure_optimizers(self): - if self.lr is None: - optimizer = optim.Adam(self.model.parameters(), lr=self.lr) - return optimizer - elif self.lr_scheduler == 'step': - optimizer = optim.Adam(self.model.parameters(), lr=self.lr) - lr_scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=self.trainer.max_epochs//2, gamma=0.1) - return {'optimizer': optimizer, - 'lr_scheduler': lr_scheduler} + optimizers_ = {} + # default Adam + if self.optimizer.name == "AdamW": + optimizers_['optimizer'] = optim.AdamW(self.model.parameters(), lr=self.optimizer.lr, weight_decay=self.optimizer.get("weight_decay", 1e-4)) + else: # if self.optimizer.name == "Adam": + optimizers_['optimizer'] = optim.Adam(self.model.parameters(), lr=self.optimizer.lr) + + if "scheduler" in self.optimizer: + if self.optimizer.scheduler.name == "WarmupCosLR": + optimizers_['lr_scheduler'] = WarmupCosLR(optimizers_['optimizer'], self.optimizer.scheduler.get("min_lr", self.optimizer.lr*0.1), \ + self.optimizer.lr, self.optimizer.scheduler.get("warmup_epochs", 1), self.epochs) + elif self.optimizer.scheduler.name == "StepLR": + optimizers_['lr_scheduler'] = optim.lr_scheduler.StepLR(optimizers_['optimizer'], step_size=self.optimizer.scheduler.get("step_size", self.trainer.max_epochs//3), \ + gamma=self.optimizer.scheduler.get("gamma", 0.1)) + + return optimizers_ def on_train_epoch_start(self): self.time_start_train_epoch = time.time() @@ -191,8 +209,8 @@ def on_train_epoch_end(self): def on_validation_epoch_end(self): self.model.timer.print(random_colors=False, bold=False) - if self.av2_mode == 'test': - print(f"\nModel: {self.model.__class__.__name__}, Checkpoint from: {self.load_checkpoint_path}") + if self.data_mode == 'test': + print(f"\nModel: {self.model.__class__.__name__}, Checkpoint from: {self.checkpoint}") print(f"Test results saved in: {self.save_res_path}, Please run submit command and upload to online leaderboard for results.") if self.leaderboard_version == 1: print(f"\nevalai challenge 2010 phase 4018 submit --file {self.save_res_path}.zip --large --private\n") @@ -204,9 +222,9 @@ def on_validation_epoch_end(self): # wandb.log_artifact(output_file) return - if self.av2_mode == 'val': - print(f"\nModel: {self.model.__class__.__name__}, Checkpoint from: {self.load_checkpoint_path}") - print(f"More details parameters and training status are in checkpoints") + if self.data_mode == 'val': + print(f"\nModel: {self.model.__class__.__name__}, Checkpoint from: {self.checkpoint}") + print(f"More details parameters and training status are in the checkpoint file.") self.metrics.normalize() @@ -223,8 +241,8 @@ def on_validation_epoch_end(self): # Save the dictionaries to a pickle file with open(str(self.save_res_path)+'.pkl', 'wb') as f: pickle.dump((self.metrics.epe_3way, self.metrics.bucketed, self.metrics.epe_ssf), f) - print(f"We already write the {self.vis_name} into the dataset, please run following commend to visualize the flow. Copy and paste it to your terminal:") - print(f"python tools/visualization.py --res_name '{self.vis_name}' --data_dir {self.dataset_path}") + print(f"We already write the {self.res_name} into the dataset, please run following commend to visualize the flow. Copy and paste it to your terminal:") + print(f"python tools/visualization.py --res_name '{self.res_name}' --data_dir {self.dataset_path}") print(f"Enjoy! ^v^ ------ \n") self.metrics = OfficialMetrics() @@ -247,7 +265,7 @@ def eval_only_step_(self, batch, res_dict): else: final_flow[~batch['gm0']] = res_dict['flow'] + pose_flow[~batch['gm0']] - if self.av2_mode == 'val': # since only val we have ground truth flow to eval + if self.data_mode == 'val': # since only val we have ground truth flow to eval gt_flow = batch["flow"] v1_dict = evaluate_leaderboard(final_flow[eval_mask], pose_flow[eval_mask], pc0[eval_mask], \ gt_flow[eval_mask], batch['flow_is_valid'][eval_mask], \ @@ -259,7 +277,7 @@ def eval_only_step_(self, batch, res_dict): self.metrics.step(v1_dict, v2_dict, ssf_dict) # NOTE (Qingwen): Since val and test, we will force set batch_size = 1 - if self.save_res or self.av2_mode == 'test': # test must save data to submit in the online leaderboard. + if self.save_res or self.data_mode == 'test': # test must save data to submit in the online leaderboard. save_pred_flow = final_flow[eval_mask, :3].cpu().detach().numpy() rigid_flow = pose_flow[eval_mask, :3].cpu().detach().numpy() is_dynamic = np.linalg.norm(save_pred_flow - rigid_flow, axis=1, ord=2) >= 0.05 @@ -273,8 +291,10 @@ def run_model_wo_ground_data(self, batch): batch['origin_pc0'] = batch['pc0'].clone() batch['pc0'] = batch['pc0'][~batch['gm0']].unsqueeze(0) batch['pc1'] = batch['pc1'][~batch['gm1']].unsqueeze(0) - if 'pcb0' in batch: - batch['pcb0'] = batch['pcb0'][~batch['gmb0']].unsqueeze(0) + + for i in range(1, self.num_frames-1): + batch[f'pch{i}'] = batch[f'pch{i}'][~batch[f'gmh{i}']].unsqueeze(0) + self.model.timer[12].start("One Scan") res_dict = self.model(batch) self.model.timer[12].stop() @@ -285,7 +305,7 @@ def run_model_wo_ground_data(self, batch): return batch, res_dict def validation_step(self, batch, batch_idx): - if self.av2_mode == 'val' or self.av2_mode == 'test': + if self.data_mode in ['val', 'test']: batch, res_dict = self.run_model_wo_ground_data(batch) self.model.timer[13].start("Eval") self.eval_only_step_(batch, res_dict) @@ -317,13 +337,13 @@ def test_step(self, batch, batch_idx): key = str(batch['timestamp']) scene_id = batch['scene_id'] with h5py.File(os.path.join(self.dataset_path, f'{scene_id}.h5'), 'r+') as f: - if self.vis_name in f[key]: - del f[key][self.vis_name] - f[key].create_dataset(self.vis_name, data=final_flow.cpu().detach().numpy().astype(np.float32)) + if self.res_name in f[key]: + del f[key][self.res_name] + f[key].create_dataset(self.res_name, data=final_flow.cpu().detach().numpy().astype(np.float32)) def on_test_epoch_end(self): self.model.timer.print(random_colors=False, bold=False) - print(f"\n\nModel: {self.model.__class__.__name__}, Checkpoint from: {self.load_checkpoint_path}") + print(f"\n\nModel: {self.model.__class__.__name__}, Checkpoint from: {self.checkpoint}") print(f"We already write the flow_est into the dataset, please run following commend to visualize the flow. Copy and paste it to your terminal:") - print(f"python tools/visualization.py --res_name '{self.vis_name}' --data_dir {self.dataset_path}") + print(f"python tools/visualization.py --res_name '{self.res_name}' --data_dir {self.dataset_path}") print(f"Enjoy! ^v^ ------ \n") diff --git a/src/utils/mics.py b/src/utils/mics.py index c3385b7..795e585 100644 --- a/src/utils/mics.py +++ b/src/utils/mics.py @@ -285,7 +285,7 @@ def __getitem__(self, index): data_dict['pc0'] = f[key]['lidar'][:] data_dict['gm0'] = f[key]['ground_mask'][:] data_dict['pose0'] = f[key]['pose'][:] - for flow_key in self.vis_name + ['dufo_label', 'label']: + for flow_key in self.vis_name + ['dufo', 'label']: if flow_key in f[key]: data_dict[flow_key] = f[key][flow_key][:] diff --git a/tools/visualization.py b/tools/visualization.py index 9cd6651..e1d5e41 100644 --- a/tools/visualization.py +++ b/tools/visualization.py @@ -68,7 +68,7 @@ def check_flow( o3d_vis.update([pcd, pcd1, pcd2, o3d.geometry.TriangleMesh.create_coordinate_frame(size=2)]) def vis( - data_dir: str ="/home/kin/data/av2/preprocess/sensor/mini", + data_dir: str ="/home/kin/data/av2/h5py/demo/val", res_name: str = "flow", # any res_name we write before in HDF5Data start_id: int = 0, point_size: float = 2.0, @@ -124,7 +124,7 @@ def vis( def vis_multiple( - data_dir: str ="/home/kin/data/av2/preprocess/sensor/mini", + data_dir: str ="/home/kin/data/av2/h5py/demo/val", res_name: list = ["flow"], start_id: int = 0, point_size: float = 3.0, diff --git a/tools/write4conf.py b/tools/write4conf.py index 60cc560..bb42431 100644 --- a/tools/write4conf.py +++ b/tools/write4conf.py @@ -17,7 +17,14 @@ def main( model['hyper_parameters']['cfg']['model']['target']['_target_'] = new_path torch.save(model, output_path) +def readmodel( + model_path: str = "/home/kin/model_zoo/seflowpp.ckpt" +): + model = torch.load(model_path) + print(model['hyper_parameters']['cfg'], model['epoch']) + if __name__ == '__main__': start_time = time.time() - fire.Fire(main) + # fire.Fire(main) + fire.Fire(readmodel) print(f"Time used: {time.time() - start_time:.2f} s") \ No newline at end of file diff --git a/train.py b/train.py index 51be0b0..a9a563c 100644 --- a/train.py +++ b/train.py @@ -29,8 +29,8 @@ from src.trainer import ModelWrapper def precheck_cfg_valid(cfg): - if cfg.loss_fn == 'seflowLoss' and cfg.add_seloss is None: - raise ValueError("Please specify the self-supervised loss items for seflowLoss.") + if cfg.loss_fn in ['seflowLoss', 'seflowppLoss'] and (cfg.add_seloss is None or cfg.ssl_label is None): + raise ValueError("Please specify the self-supervised loss items and auto-label source for seflow-series loss.") grid_size = [(cfg.point_cloud_range[3] - cfg.point_cloud_range[0]) * (1/cfg.voxel_size[0]), (cfg.point_cloud_range[4] - cfg.point_cloud_range[1]) * (1/cfg.voxel_size[1]), @@ -57,7 +57,7 @@ def main(cfg): precheck_cfg_valid(cfg) pl.seed_everything(cfg.seed, workers=True) - train_dataset = HDF5Dataset(cfg.train_data, n_frames=cfg.num_frames, dufo=(cfg.loss_fn == 'seflowLoss')) + train_dataset = HDF5Dataset(cfg.train_data, n_frames=cfg.num_frames, ssl_label=cfg.get('ssl_label', None)) train_loader = DataLoader(train_dataset, batch_size=cfg.batch_size, shuffle=True, @@ -76,10 +76,11 @@ def main(cfg): output_dir = HydraConfig.get().runtime.output_dir # overwrite logging folder name for SSL. - if cfg.loss_fn == 'seflowLoss': - cfg.output = cfg.output.replace(cfg.model.name, "seflow") - output_dir = output_dir.replace(cfg.model.name, "seflow") - method_name = "seflow" + if cfg.loss_fn in ['seflowLoss', 'seflowppLoss']: + tmp_ = cfg.loss_fn.split('Loss')[0] + '-' + cfg.model.name + cfg.output = cfg.output.replace(cfg.model.name, tmp_) + output_dir = output_dir.replace(cfg.model.name, tmp_) + method_name = tmp_ else: method_name = cfg.model.name @@ -133,14 +134,15 @@ def main(cfg): print("Initiating wandb and trainer successfully. ^V^ ") print(f"We will use {cfg.gpus} GPUs to train the model. Check the checkpoints in {output_dir} checkpoints folder.") print("Total Train Dataset Size: ", len(train_dataset)) - if cfg.add_seloss is not None and cfg.loss_fn == 'seflowLoss': + if cfg.get('add_seloss', None) is not None and cfg.loss_fn in ['seflowLoss', 'seflowppLoss']: print(f"Note: We are in **self-supervised** training now. No ground truth label is used.") print(f"We will use these loss items in {cfg.loss_fn}: {cfg.add_seloss}") print("-"*40+"\n") # NOTE(Qingwen): search & check: def training_step(self, batch, batch_idx) trainer.fit(model, train_dataloaders = train_loader, val_dataloaders = val_loader, ckpt_path = cfg.checkpoint) - wandb.finish() + if cfg.wandb_mode != "disabled": + wandb.finish() if __name__ == "__main__": main() \ No newline at end of file