13 minute read

NeRF의 Coordinate System을 이해해보자

주의: $^{C}{T}_W$ = world-to-camera = w2c, $^{W}{T}_C$ = camera-to-world = c2w

  • 좌표계 변환에서 수식을 읽을 때는, 우측하단의 좌표계에서 좌측상단의 좌표계로 이동하는 변환으로 정의됩니다.
  • $^{C}{T}_W$는 따라서 우측하단의 World Coordinate System에서 좌측상단의 Camera Coordinate System으로의 변환입니다.
  • 그 의미로 인해 $^{C}{T}_W$는 변수명으로는 world-to-camera인 w2c으로 정의합니다.
  • 반대의 경우도 마찬가지입니다.
  • $^{W}{T}_C$은 우측하단의 Camera Coordinate System에서 좌측상단의 World Coordinate System으로의 변환입니다.
  • 그 의미로 인해 $^{W}{T}_C$는 변수명으로는 camera-to-world인 c2w으로 정의합니다.
  • 코딩 스타일에 따라 w2cW2C로, c2wC2W로 대문자로 변수를 쓰는 사람도 있습니다.

NeRF 데이터셋은 기본적으로 각 카메라 포즈에 대한 정보는 World에서 각 Camera까지의 변환을 모은 것이라고 생각하면 됩니다.

  • 코드에 따라 다를 수 있지만, 기본적으로 W2C로 정의하여 불러 옵니다. (사람에 따라 W2C을 C2W라고 정의해버리는 경우도 있으니 주의해야합니다.)

  • MipNeRF360(NeRF++) 데이터를 불러오는 부분을 봅시다. cam마다 cam_info에서 RT를 불러와서 W2C (world to camera) 변환을 정의해줍니다.
  • C2W (camera to world)W2C (world to camera)를 역변환하여 정의합니다.
  • 만약 카메라의 center을 알고 싶다면 World Coordinate System을 기준으로 정의해야합니다.
  • 그러므로 카메라가 World Coordinate System을 기준으로 할 때의 translation이 카메라의 center가 됩니다.
  • C2W (camera to world)는 Camera Coordinate System에서 World Coordinate System으로 변환해줍니다.
  • 즉, C2W (camera to world)는 World Coordinate System이 기준일 때, 카메라의 Rotation과 Translation을 포함하는 행렬입니다.
  • 따라서, C2W (camera to world)에서 마지막 열을 인덱싱하면 World Coordinate System에서 카메라의 center 위치를 얻는 것입니다.

C2W를 수식으로 쓰면 다음과 같습니다.

  • C2W 변환 행렬은 카메라 좌표계에서 월드 좌표계로 변환하는 행렬입니다. 이 행렬은 보통 회전 행렬 $R$과 변환 벡터 $T$로 구성됩니다.
\[C2W = \begin{bmatrix} R & T \\ 0 & 1 \end{bmatrix}\]
  • 여기서 $R$은 3x3 회전 행렬이고, $T$는 3x1 변환 벡터입니다. 보다 구체적으로 표현하면:
\[C2W = \begin{bmatrix} R_{11} & R_{12} & R_{13} & T_x \\ R_{21} & R_{22} & R_{23} & T_y \\ R_{31} & R_{32} & R_{33} & T_z \\ 0 & 0 & 0 & 1 \end{bmatrix}\]
  • 여기서 C2W # shape 4x4에서, C2W[:3, 3:4]와 같이 취하면 World Coordinate System 기준 camera center 위치를 구할 수 있습니다.
\[camera \ center = \begin{bmatrix} T_x \\ T_y \\ T_z \\ \end{bmatrix}\]
  • 이와 같이 camera center를 다룰 때에는 World Coordinate System로 기준 좌표계로 바꿔주고 계산을 해야합니다.
  • 그런 다음 다시 Camera Coordinate System으로 좌표계를 변경해줘야 합니다.

즉, World Coordinate System에서 카메라의 포즈를 조작할 때는

  1. 먼저 C2W (camera to world)에서 Rotation과 Translation을 변경해주고,
  2. C2W (camera to world)에 inverse를 취하여 다시 W2C (world to camera) 변환을 얻어주어 사용합니다.

getWorld2View(R, t)

  • getWorld2View(R, t)는 단순히 W2C을 구성하는 R, tW2C (world to camera) 4x4 변환을 반환하는 함수
# 3dgs/utils/graphics_utils.py

def getWorld2View(R, t):
  Rt = np.zeros((4, 4))
  Rt[:3, :3] = R.transpose()
  Rt[:3, 3] = t
  Rt[3, 3] = 1.0
  return np.float32(Rt)

getWorld2View2(R, t, translate=np.array([.0, .0, .0]), scale=1.0)

  • getWorld2View2(R, t, translate=np.array([.0, .0, .0]), scale=1.0)W2C을 구성하는 R, tW2C (world to camera) 4x4 변환을 구성하고, inverse를 취하여 C2W (camera to world)로 변환한 후에 camera center에 대해 translate와 scale을 조정하고, 다시 inverse를 취한 W2C (world to camera) 4x4 변환을 반환하는 함수
# 3dgs/utils/graphics_utils.py

def getWorld2View2(R, t, translate=np.array([.0, .0, .0]), scale=1.0):
    Rt = np.zeros((4, 4))
    Rt[:3, :3] = R.transpose()
    Rt[:3, 3] = t
    Rt[3, 3] = 1.0

    C2W = np.linalg.inv(Rt)
    cam_center = C2W[:3, 3]
    cam_center = (cam_center + translate) * scale
    C2W[:3, 3] = cam_center
    Rt = np.linalg.inv(C2W)
    return np.float32(Rt)

카메라 포즈의 translate와 scale을 조작할 수 있는 getWorld2View2(R, t, translate=np.array([.0, .0, .0]), scale=1.0)의 활용 예시를 알아봅시다.

1. 먼저 SceneInfo에서 getWorld2View2(R, t, translate=np.array([.0, .0, .0]), scale=1.0)로 계산한 nerf_normalization 변수로 Scene의 범위를 조작하는 예시를 살펴봅시다.

  • 아래 코드에선 getWorld2View2(cam.R, cam.T, translate=np.array([.0, .0, .0]), scale=1.0)이므로 따로 camera center에 대한 translate과 scale을 조절하지 않았습니다.
  • get_center_and_diag(cam_centers)
    • cam_centersC2W[:3, 3:4]로 모든 카메라에 대한 cam_centers를 list 형태로 모아줍니다.
    • C2W의 모든 translation에 대한 평균을 구하여, avg_cam_center를 구합니다.
    • avg_cam_center와 가장 거리가 멀리 떨어진 카메라까지의 거리를 diagonal로 구합니다.
      • 반환된 avg_cam_centercenter로 반환되어 평균적으로 카메라까지의 거리를 구할 때 translate = -center로써 사용됩니다.
      • 반환된 diagonalavg_cam_center에서 가장 멀리 떨어진 카메라까지의 거리이고, 이에 1.1을 곱하여 scene의 최대 반지름 반경을 설정하는데 사용합니다.
      • 이는 nerf_normalization 변수로 반환되어 readColmapSceneInforeadNeRFSyntheticInfoSceneInfo에 전달되어 사용됩니다.
# 3dgs/scene/dataset_reader.py

def getNerfppNorm(cam_info):
    def get_center_and_diag(cam_centers):
        cam_centers = np.hstack(cam_centers)
        avg_cam_center = np.mean(cam_centers, axis=1, keepdims=True)
        center = avg_cam_center
        dist = np.linalg.norm(cam_centers - center, axis=0, keepdims=True)
        diagonal = np.max(dist)
        return center.flatten(), diagonal

    cam_centers = []

    for cam in cam_info:
        W2C = getWorld2View2(cam.R, cam.T)
        C2W = np.linalg.inv(W2C)
        cam_centers.append(C2W[:3, 3:4])

    center, diagonal = get_center_and_diag(cam_centers)
    radius = diagonal * 1.1

    translate = -center

    return {"translate": translate, "radius": radius}
# 3dgs/scene/dataset_reader.py

def readColmapSceneInfo(path, images, eval, llffhold=8):
    try:
        cameras_extrinsic_file = os.path.join(path, "sparse/0", "images.bin")
        cameras_intrinsic_file = os.path.join(path, "sparse/0", "cameras.bin")
        cam_extrinsics = read_extrinsics_binary(cameras_extrinsic_file)
        cam_intrinsics = read_intrinsics_binary(cameras_intrinsic_file)
    except:
        cameras_extrinsic_file = os.path.join(path, "sparse/0", "images.txt")
        cameras_intrinsic_file = os.path.join(path, "sparse/0", "cameras.txt")
        cam_extrinsics = read_extrinsics_text(cameras_extrinsic_file)
        cam_intrinsics = read_intrinsics_text(cameras_intrinsic_file)

    reading_dir = "images" if images == None else images
    cam_infos_unsorted = readColmapCameras(cam_extrinsics=cam_extrinsics, cam_intrinsics=cam_intrinsics, images_folder=os.path.join(path, reading_dir))
    cam_infos = sorted(cam_infos_unsorted.copy(), key = lambda x : x.image_name)

    if eval:
        train_cam_infos = [c for idx, c in enumerate(cam_infos) if idx % llffhold != 0]
        test_cam_infos = [c for idx, c in enumerate(cam_infos) if idx % llffhold == 0]
    else:
        train_cam_infos = cam_infos
        test_cam_infos = []

    nerf_normalization = getNerfppNorm(train_cam_infos)

    ply_path = os.path.join(path, "sparse/0/points3D.ply")
    bin_path = os.path.join(path, "sparse/0/points3D.bin")
    txt_path = os.path.join(path, "sparse/0/points3D.txt")
    if not os.path.exists(ply_path):
        print("Converting point3d.bin to .ply, will happen only the first time you open the scene.")
        try:
            xyz, rgb, _ = read_points3D_binary(bin_path)
        except:
            xyz, rgb, _ = read_points3D_text(txt_path)
        storePly(ply_path, xyz, rgb)
    try:
        pcd = fetchPly(ply_path)
    except:
        pcd = None

    scene_info = SceneInfo(point_cloud=pcd,
                           train_cameras=train_cam_infos,
                           test_cameras=test_cam_infos,
                           nerf_normalization=nerf_normalization,
                           ply_path=ply_path)
    return scene_info
# 3dgs/scene/dataset_reader.py

def readNerfSyntheticInfo(path, white_background, eval, extension=".png"):
    print("Reading Training Transforms")
    train_cam_infos = readCamerasFromTransforms(path, "transforms_train.json", white_background, extension)
    print("Reading Test Transforms")
    test_cam_infos = readCamerasFromTransforms(path, "transforms_test.json", white_background, extension)
    
    if not eval:
        train_cam_infos.extend(test_cam_infos)
        test_cam_infos = []

    nerf_normalization = getNerfppNorm(train_cam_infos)

    ply_path = os.path.join(path, "points3d.ply")
    if not os.path.exists(ply_path):
        # Since this data set has no colmap data, we start with random points
        num_pts = 100_000
        print(f"Generating random point cloud ({num_pts})...")
        
        # We create random points inside the bounds of the synthetic Blender scenes
        xyz = np.random.random((num_pts, 3)) * 2.6 - 1.3
        shs = np.random.random((num_pts, 3)) / 255.0
        pcd = BasicPointCloud(points=xyz, colors=SH2RGB(shs), normals=np.zeros((num_pts, 3)))

        storePly(ply_path, xyz, SH2RGB(shs) * 255)
    try:
        pcd = fetchPly(ply_path)
    except:
        pcd = None
    scene_info = SceneInfo(point_cloud=pcd,
                           train_cameras=train_cam_infos,
                           test_cameras=test_cam_infos,
                           nerf_normalization=nerf_normalization,
                           ply_path=ply_path)
    return scene_info
  • nerf_normalizationscene_info.nerf_normalization["radius"]로 접근하여 self.cameras_extent로 사용합니다.
  • self.cameras_extentself.spatial_lr_scale로 사용되어 position_lr_init의 learning rate를 조절하는데 사용합니다.
  • self.spatial_lr_scale을 정의하면 class GaussianModel의 어떤 함수에서든 self.spatial_lr_scale로 접근하여 사용이 가능합니다.
  • 즉, scene의 크기에 따라서 xyz에 대한 3d gaussian의 학습률을 조절하는 것입니다.
    • lr이 크면 3d gaussian의 xyz가 optimize될 때 크게 움직입니다.
    • lr이 작으면 3d gaussian의 xyz가 optimize될 때 작게 움직입니다.
# 3dgs/scene/__init__.py

class Scene:
...
    gaussians : GaussianModel
...
        self.cameras_extent = scene_info.nerf_normalization["radius"]

        for resolution_scale in resolution_scales:
            print("Loading Training Cameras")
            self.train_cameras[resolution_scale] = cameraList_from_camInfos(scene_info.train_cameras, resolution_scale, args)
            print("Loading Test Cameras")
            self.test_cameras[resolution_scale] = cameraList_from_camInfos(scene_info.test_cameras, resolution_scale, args)

        if self.loaded_iter:
            self.gaussians.load_ply(os.path.join(self.model_path,
                                                           "point_cloud",
                                                           "iteration_" + str(self.loaded_iter),
                                                           "point_cloud.ply"))
        else:
            self.gaussians.create_from_pcd(scene_info.point_cloud, self.cameras_extent)
# 3dgs/scene/gaussian_model.py

class GaussianModel:
...
    def create_from_pcd(self, pcd : BasicPointCloud, spatial_lr_scale : float):
        self.spatial_lr_scale = spatial_lr_scale
...
    def training_setup(self, training_args):
        self.percent_dense = training_args.percent_dense
        self.xyz_gradient_accum = torch.zeros((self.get_xyz.shape[0], 1), device="cuda")
        self.denom = torch.zeros((self.get_xyz.shape[0], 1), device="cuda")

        l = [
            {'params': [self._xyz], 'lr': training_args.position_lr_init * self.spatial_lr_scale, "name": "xyz"},
...
        self.optimizer = torch.optim.Adam(l, lr=0.0, eps=1e-15)
        self.xyz_scheduler_args = get_expon_lr_func(lr_init=training_args.position_lr_init*self.spatial_lr_scale,
                                                    lr_final=training_args.position_lr_final*self.spatial_lr_scale,
                                                    lr_delay_mult=training_args.position_lr_delay_mult,
                                                    max_steps=training_args.position_lr_max_steps)
...

2. CameraInfo에서 카메라 포즈를 getWorld2View2(R, t, translate=np.array([.0, .0, .0]), scale=1.0)로 계산하여 world view transform에서 translatescale을 조작할 수 있습니다.

  • self.world_view_transform = torch.tensor(getWorld2View2(R, T, trans, scale)).transpose(0, 1).cuda()에서 getWorld2View2(R, t, translate=np.array([.0, .0, .0]), scale=1.0) 함수에서 translatescaleworld coordinate system에서 카메라의 포즈의 translatescale을 조작할 수 있습니다.
# 3dgs/utils/graphcis_utils.py

def getWorld2View2(R, t, translate=np.array([.0, .0, .0]), scale=1.0):
    Rt = np.zeros((4, 4))
    Rt[:3, :3] = R.transpose()
    Rt[:3, 3] = t
    Rt[3, 3] = 1.0

    C2W = np.linalg.inv(Rt)
    cam_center = C2W[:3, 3]
    cam_center = (cam_center + translate) * scale
    C2W[:3, 3] = cam_center
    Rt = np.linalg.inv(C2W)
    return np.float32(Rt)
# 3dgs/scene/cameras.py

from utils.graphics_utils import getWorld2View2, getProjectionMatrix

class Camera(nn.Module):
    def __init__(self, colmap_id, R, T, FoVx, FoVy, image, gt_alpha_mask,
                 image_name, uid,
                 trans=np.array([0.0, 0.0, 0.0]), scale=1.0, data_device = "cuda"
                 ):
        super(Camera, self).__init__()

        self.uid = uid
        self.colmap_id = colmap_id
        self.R = R
        self.T = T
        self.FoVx = FoVx
        self.FoVy = FoVy
        self.image_name = image_name

        try:
            self.data_device = torch.device(data_device)
        except Exception as e:
            print(e)
            print(f"[Warning] Custom device {data_device} failed, fallback to default cuda device" )
            self.data_device = torch.device("cuda")

        self.original_image = image.clamp(0.0, 1.0).to(self.data_device)
        self.image_width = self.original_image.shape[2]
        self.image_height = self.original_image.shape[1]

        if gt_alpha_mask is not None:
            self.original_image *= gt_alpha_mask.to(self.data_device)
        else:
            self.original_image *= torch.ones((1, self.image_height, self.image_width), device=self.data_device)

        self.zfar = 100.0
        self.znear = 0.01

        self.trans = trans
        self.scale = scale

        self.world_view_transform = torch.tensor(getWorld2View2(R, T, trans, scale)).transpose(0, 1).cuda()
        self.projection_matrix = getProjectionMatrix(znear=self.znear, zfar=self.zfar, fovX=self.FoVx, fovY=self.FoVy).transpose(0,1).cuda()
        self.full_proj_transform = (self.world_view_transform.unsqueeze(0).bmm(self.projection_matrix.unsqueeze(0))).squeeze(0)
        self.camera_center = self.world_view_transform.inverse()[3, :3]

  • 추가적으로 self.camera_cetnerself.world_view_transform.inverse()[3, :3]로 얻는 것을 볼 수 있습니다.
  • cameraview는 같은 의미로 사용됩니다.
  • 따라서 self.world_view_transformw2c를 의미합니다.
  • inverse를 취하면 self.world_view_transform.inverse()c2w를 의미하게 됩니다.
  • c2wcameraworld coordinate system으로 좌표변환을 했을 때 camera가 어디에 어떻게 좌표축이 돌아간 상태로 위치하는가? 를 의미합니다. 이는 world coordinate system에서 camera의 pose를 의미합니다.
  • self.world_view_transform.inverse()[3, :3]world coordinate system에서 camera의 pose중, 마지막 열에 해당하는 translate성분이므로, world coordinate system에서 camera_center를 의미합니다.
  • 이때 일반적으로 4x4 행렬에서 마지막 열인 translate를 인덱싱하는 [:3, 3]가 아니라 [3, :3]으로 코딩된 이유는, 앞에서 self.world_view_transformtranspose(0, 1)하였기 때문입니다.
          self.world_view_transform = torch.tensor(getWorld2View2(R, T, trans, scale)).transpose(0, 1).cuda()
    ...
          self.camera_center = self.world_view_transform.inverse()[3, :3]
    
    • transposew2cself.world_view_transform에서 [3, :3]으로 인덱싱하면 마지막 행을 얻으므로 translate 정보를 얻을 수 있습니다.
    • 아래와 같은 행렬에서 [T_x, T_y, T_z]을 인덱싱하여 얻어 self.camera_center를 정의하는 것입니다.
\[W2C^T = \begin{bmatrix} R_{11} & R_{21} & R_{31} & 0 \\ R_{12} & R_{22} & R_{32} & 0 \\ R_{13} & R_{23} & R_{33} & 0 \\ T_x & T_y & T_z & 1 \end{bmatrix}\]

dataset_readers.pyR에서부터 CUDA code 연산을 위해 미리 Rtranspose()가 되어있습니다.

  • Rtranspose()된 부분은 아래의 dataset_readers.pyreadColmapCamerasreadCamerasFromTransforms 함수에서 모두 확인 가능합니다.
# 3dgs/scene/dataset_readers.py

def readColmapCameras(cam_extrinsics, cam_intrinsics, images_folder):
    cam_infos = []
    for idx, key in enumerate(cam_extrinsics):
        sys.stdout.write('\r')
        # the exact output you're looking for:
        sys.stdout.write("Reading camera {}/{}".format(idx+1, len(cam_extrinsics)))
        sys.stdout.flush()

        extr = cam_extrinsics[key]
        intr = cam_intrinsics[extr.camera_id]
        height = intr.height
        width = intr.width

        uid = intr.id
        R = np.transpose(qvec2rotmat(extr.qvec))
        T = np.array(extr.tvec)

...

def readCamerasFromTransforms(path, transformsfile, white_background, extension=".png"):
    cam_infos = []

    with open(os.path.join(path, transformsfile)) as json_file:
        contents = json.load(json_file)
        fovx = contents["camera_angle_x"]

        frames = contents["frames"]
        for idx, frame in enumerate(frames):
            cam_name = os.path.join(path, frame["file_path"] + extension)

            # NeRF 'transform_matrix' is a camera-to-world transform
            c2w = np.array(frame["transform_matrix"])
            # change from OpenGL/Blender camera axes (Y up, Z back) to COLMAP (Y down, Z forward)
            c2w[:3, 1:3] *= -1

            # get the world-to-camera transform and set R, T
            w2c = np.linalg.inv(c2w)
            R = np.transpose(w2c[:3,:3])  # R is stored transposed due to 'glm' in CUDA code
            T = w2c[:3, 3]

...

  • 위처럼 CUDA code 연산을 위해, dataset_readers.pyreadColmapCamerasreadCamerasFromTransforms에서 불러온 RR.transpose()해버린 상태입니다.
  • 따라서 CUDA code가 아닌 transpose되지 않은 4x4 일반적인 카메라 포즈 연산을 하기위해 getWorld2View, getWorld2View2에서는 R을 다시 transpose()하여 사용합니다.
  • 즉, getWorld2View에서는 Rtranspose를 하여 4x4 카메라 포즈로써 계산하기 위해, dataset_readers.py에서 transpose되었던 R을 다시 R.transpose()를 하고, W2C 형태로 구성하여 반환합니다.
  • getWorld2View2RgetWorld2View처럼 R.transpose()하고 최종적으로 W2C을 반환하는 것은 동일하지만, 중간에 W2CC2W로 inverse하여 world coordinate system에서 camera의 posetranslate, scale 값으로 조절하고, 이를 다시 inverse한 W2C로 반환합니다.
  • transpose되지 않은 일반적인 4x4 W2C의 형태는 아래와 같습니다.
\[W2C = \begin{bmatrix} R_{11} & R_{12} & R_{13} & T_x \\ R_{21} & R_{22} & R_{23} & T_y \\ R_{31} & R_{32} & R_{33} & T_z \\ 0 & 0 & 0 & 1 \end{bmatrix}\]
# 3dgs/utils/graphics_utils.py

def getWorld2View(R, t):
    Rt = np.zeros((4, 4))
    Rt[:3, :3] = R.transpose()
    Rt[:3, 3] = t
    Rt[3, 3] = 1.0
    return np.float32(Rt)

def getWorld2View2(R, t, translate=np.array([.0, .0, .0]), scale=1.0):
    Rt = np.zeros((4, 4))
    Rt[:3, :3] = R.transpose()
    Rt[:3, 3] = t
    Rt[3, 3] = 1.0

    C2W = np.linalg.inv(Rt)
    cam_center = C2W[:3, 3]
    cam_center = (cam_center + translate) * scale
    C2W[:3, 3] = cam_center
    Rt = np.linalg.inv(C2W)
    return np.float32(Rt)
  • utils/camera_utils.py에서도 dataset_readers.py에서 불러올때 transpose했던 R을 다시 R.transpose()하여 일반적인 4X4 형태의 W2C으로 저장하고 있습니다.
  • 이때, 이 함수가 넘겨받는 R,tC2W에 해당하므로 inverse를 취하면 W2C이 됩니다.
# 3dgs/utils/camera_utils.py

...

def camera_to_JSON(id, camera : Camera):
    Rt = np.zeros((4, 4))
    Rt[:3, :3] = camera.R.transpose()
    Rt[:3, 3] = camera.T
    Rt[3, 3] = 1.0

    W2C = np.linalg.inv(Rt)
    pos = W2C[:3, 3]
    rot = W2C[:3, :3]
    serializable_array_2d = [x.tolist() for x in rot]
    camera_entry = {
        'id' : id,
        'img_name' : camera.image_name,
        'width' : camera.width,
        'height' : camera.height,
        'position': pos.tolist(),
        'rotation': serializable_array_2d,
        'fy' : fov2focal(camera.FovY, camera.height),
        'fx' : fov2focal(camera.FovX, camera.width)
    }
    return camera_entry

(4,4) 변환행렬에 대해 transpose(0, 1)하는 이유 & batch matrix multiplication을 하는 이유

  • getWorld2View2(R, T, trans, scale)W2C: World to Camera(=View Space)(4,4) 변환행렬을 반환합니다.
  • self.world_view_transformW2C을 받아 transpose(0, 1)을 한 W2C.transpose(0, 1)(4,4) 변환행렬 입니다.
  • 이처럼 transpose(0, 1)를 하는 이유는 컴퓨터 그래픽스에서 열 벡터(column vector)를 연산으로 사용하는 규칙을 따르기 위해서입니다.
        self.world_view_transform = torch.tensor(getWorld2View2(R, T, trans, scale)).transpose(0, 1).cuda()
        self.projection_matrix = getProjectionMatrix(znear=self.znear, zfar=self.zfar, fovX=self.FoVx, fovY=self.FoVy).transpose(0,1).cuda()
        self.full_proj_transform = (self.world_view_transform.unsqueeze(0).bmm(self.projection_matrix.unsqueeze(0))).squeeze(0)
  • self.world_view_transformworld to view space입니다.
    • view spacecamera space는 같은 말입니다.
  • self.projection_matrxview to clip space입니다.
  • self.full_proj_transformworld to clip space = world to view space @ view to clip space입니다.
    • 즉, self.full_proj_transformworld to clip space입니다.
  • self.world_view_transform, self.projection_matrix는 모두 컴퓨터 그래픽스 관례에 따라 transpose(0, 1)이 된 상태에서 unsqueeze(0)하여 배치차원을 추가하고 batch matrix multilplication, bmm 연산하고 squeeze(0)으로 배치차원을 제거하여 self.full_proj_transform (4,4) 변환행렬을 얻습니다.

transpose(0, 1)을 하는 이유

  • 행렬의 전치(transpose)하는 이유는 그래픽스에서는 보통 좌표 변환을 행렬 곱셈으로 처리합니다.
  • 그러나 연산 규칙상 행 벡터(row vector)를 사용할 때와 열 벡터(column vector)를 사용할 때 차이가 있습니다.
  • OpenGL 등의 그래픽스 라이브러리에서는 열 벡터를 사용하는데, 이 경우 변환 행렬이 오른쪽에 곱해지도록 합니다.
  • 이와 맞추기 위해 변환 행렬을 전치하여 사용하는 경우가 많습니다.

batch 차원을 굳이 1개 추가하고 bmm을 사용하는 이유

  • unsqueeze(0)를 사용하여 (4, 4)에서 (1, 4, 4)로 변환한 후 bmm을 사용하는 이유:

  • 통일된 연산 방식: PyTorch에서 bmm (batch matrix-matrix multiplication) 연산은 (b, n, m)과 (b, m, p) 크기의 텐서 두 개를 입력으로 받아서 (b, n, p) 크기의 텐서를 출력합니다. 여기서 b는 배치 크기를 나타냅니다.
    • 연산의 일관성을 유지하고 코드의 일반성을 높이기 위해, 단일 행렬 곱셈의 경우에도 배치 차원을 추가하여 bmm을 사용할 수 있습니다.
  • 확장성: 코드가 단일 행렬 곱셈뿐만 아니라 다수의 행렬 곱셈을 동시에 처리하도록 쉽게 확장될 수 있습니다.
    • 예를 들어, 여러 개의 world_view_transform과 projection_matrix를 한 번에 곱셈하고자 할 때 유용합니다. 이 경우 배치 크기 b는 곱할 행렬 쌍의 수가 됩니다.
  • CUDA 효율성: 배치 연산은 GPU의 병렬 처리 능력을 극대화할 수 있도록 도와줍니다. bmm을 사용하면 여러 행렬 곱셈을 병렬로 수행할 수 있어 계산 속도가 빨라집니다.

코드 예시 여기서는 단일 행렬 곱셈을 예로 들어 설명합니다:

import torch

# 예제 행렬
world_view_transform = torch.tensor([[-7.8894e-01, -1.5001e-01,  5.9588e-01,  0.0000e+00],
                                     [-6.1447e-01,  1.9260e-01, -7.6507e-01,  0.0000e+00],
                                     [ 1.9804e-09, -9.6974e-01, -2.4413e-01,  0.0000e+00],
                                     [ 5.2340e-08, -3.5459e-10,  4.0311e+00,  1.0000e+00]]).cuda()

projection_matrix = torch.tensor([[ 2.7778,  0.0000,  0.0000,  0.0000],
                                  [ 0.0000,  2.7778,  0.0000,  0.0000],
                                  [ 0.0000,  0.0000,  1.0001,  1.0000],
                                  [ 0.0000,  0.0000, -0.0100,  0.0000]]).cuda()

# batch dimension 추가
world_view_transform = world_view_transform.unsqueeze(0)  # shape: (1, 4, 4)
projection_matrix = projection_matrix.unsqueeze(0)        # shape: (1, 4, 4)

# bmm을 사용하여 행렬 곱셈 수행
full_proj_transform = world_view_transform.bmm(projection_matrix)  # shape: (1, 4, 4)

# batch dimension 제거
full_proj_transform = full_proj_transform.squeeze(0)  # shape: (4, 4)

print(full_proj_transform)
  • 추가 설명
    • 단일 행렬 곱셈의 경우: torch.matmul이나 @ 연산자를 사용해도 되지만, 일관성을 위해 batch 차원을 추가하고 bmm을 사용합니다.
    • 여러 행렬 곱셈의 경우: batch 차원을 추가한 후 bmm을 사용하면 여러 행렬 곱셈을 한 번에 처리할 수 있어 효율적입니다.

W2C, C2W 시각화

Image

Custom Camera Poses 시각화

Image

  • Custom dataset에서 camera는 고정하고 face가 돌아가는 상태로 촬영하였습니다.
  • 여기서 f2c이라고 정의한 것은 c2f = c2w @ w2g @ g2f의 연산을 정의한 것입니다.
  • c2f를 inverse하여 f2cface to camera라고 정의하고 wcs=np.eyes(4)인 월드좌표계에서 plot 했습니다.
  • 결과를 보면, world를 기준으로 camera의 pose들이 plot 됐습니다.
  • 결론적으론, camera의 좌표계 혹은 poseworld 좌표계에서 봤을 때, 카메라들이 어디에 어떻게 좌표축이 돌아간 상태로 위치하는지 plot한 것입니다.
  • 이는 c2w와 같은 말입니다.
  • 즉, 구한 f2c는 이름만 다를 뿐, 역할은 c2w의 변환과 동일합니다.
  • f2c얼굴좌표계에서 카메라좌표계까지의 변환을 수행했을 때, 변환된 pose의 위치라고 생각하시면 편합니다.
  • 즉, c2w와 같이 world를 기준으로한 camera pose들과 같은 맥락으로
  • f2cface의 위치를 world로써 기준으로한 camera pose들로 해석하면 됩니다.

NeRF synthetic dataset의 transforms_train.json, transforms_test.json에서 frame["transform_matrix"]로 정의된 c2w 시각화

Image

$^{C}{T}_W$ = world-to-camera = w2c, $^{W}{T}_C$ = camera-to-world = c2w

  • wcs=np.eyes(4)로 Identity matrix로 정의하고, nerf의 transform_matrix를 그대로 plot해보면 posewcs를 빙 둘러싼 모양으로 나옵니다.
  • 방금 우리가 plot한 것은 wcs에서 camera의 위치는 어디인가? 라는 것과 같습니다.
  • 따라서 이는 camera의 좌표계 혹은 poseworld 좌표계에서 봤을 때, 카메라들이 어디에 위치하는지를 plot한 것입니다.
  • 결론적으로, camera to world, c2w로 해석됩니다.
  • 3dgs에서는 주석으로 camera-to-world에 대한 변환인 transform_matrixc2w라는 주석으로 달아놓은 것을 확인가능합니다.
# 3dgs/scene/dataset_readers.py

...

def readCamerasFromTransforms(path, transformsfile, white_background, extension=".png"):
    cam_infos = []

    with open(os.path.join(path, transformsfile)) as json_file:
        contents = json.load(json_file)
        fovx = contents["camera_angle_x"]

        frames = contents["frames"]
        for idx, frame in enumerate(frames):
            cam_name = os.path.join(path, frame["file_path"] + extension)

            # NeRF 'transform_matrix' is a camera-to-world transform
            c2w = np.array(frame["transform_matrix"])
            # change from OpenGL/Blender camera axes (Y up, Z back) to COLMAP (Y down, Z forward)
            c2w[:3, 1:3] *= -1

            # get the world-to-camera transform and set R, T
            w2c = np.linalg.inv(c2w)
            R = np.transpose(w2c[:3,:3])  # R is stored transposed due to 'glm' in CUDA code
            T = w2c[:3, 3]
  • 중요한 점은 사람에 따라 world에서 camera로의 변환c2w라고 표기할수도 있으므로 주의해야합니다.
  • 따라서 항상 c2w, w2c이 의미하는게 무엇인지 제대로 체크하고 넘어가야합니다.

Leave a comment