프로젝트, 연구/3DGS 구현

[3DGS 구현] 5. gaussian 초기화 및 SIBR뷰어를 통한 시각화

CSE 2026. 3. 23. 11:02

 

 

 

 

<관련 글>

[CV 논문] 3D Gaussian Splatting for Real-Time Radiance Field Rendering

 

[3DGS 구현] 1. 환경 세팅

[3DGS 구현] 2. Kaggle에서 데이터셋 다운받기

[3DGS 구현] 3. COLMAP의 출력 바이너리 파일의 구조파악, 파싱

[3DGS 구현] 4. 가우시안의 초기 scale계산 및 카메라 데이터 json 변환

 

3DGS 학습시켜보기

 


이제 가우시안 그 자체를 만들기 위한 준비가 모두 되었다.

 

이제 직접 class를 만들어보자.

 

먼저 클래스 초기화이다.

 

가우시안한테는 크게 5가지의 속성이 필요하다.

1. 중심점의 3D좌표 (3차원)

2. 색상값 RGB (1x3차원) ← 그냥 3차원으로 생각하면 되는데 RGB를 한 묶음처럼 생각하겠다는 의미이다.

3. SH함수 계수 ← 원 논문에서는 3차항까지 사용하지만 나는 계산의 효율성을 위해 2차항까지만 사용할 계획이다.

4. 크기 scale (3차원)

5. 회전 정보, 쿼터니언 (4차원)

6. 불투명도 (스칼라,1차원)

 

SH함수에서 1차항 계수+2차항 계수 = 3개 + 5개 =8개 이다.

각 계수별로 채널이 3개이므로 8x3차원을 사용했다.

class GaussianModel(nn.Module):
    def __init__(self):
        super().__init__()

        self._xyz = nn.Parameter(torch.empty(0))           # 위치 (N, 3)
        self._features_dc = nn.Parameter(torch.empty(0))    # 기본색상 (N, 1, 3)
        self._features_rest = nn.Parameter(torch.empty(0))  # SH 2차항까지 계수(N,8,3)
        self._scaling = nn.Parameter(torch.empty(0))        # 크기 (N, 3) 
        self._rotation = nn.Parameter(torch.empty(0))       # 회전 (N, 4) 
        self._opacity = nn.Parameter(torch.empty(0))        # 불투명도 (N, 1)

 

이제 초기 점 정보들을 활용해 초기값을 설정하자.

points3d_dict은 Point3DLoader클래스의 load함수의 반환값으로 파싱된 COLMAP의 정보를 갖고 있는 딕셔너리이다.

  • xyz에는 $N$개의 점의 좌표가 배열로 저장되고, N x 3의 차원을 갖는다.
  • rgb에는 $N$개의 점의 색이 0~1사이의 값으로 정규화되어 배열로 저장된다. N x 3의 차원을 갖는다.
  • _xyz는 xyz를 파이토치를 위한 텐서로 바꾼 것이다. N x 3의 차원을 갖는다.
  • _features_dc는 기본 색상(=SH함수의 0차 계수)을 저장하기 위한 텐서이다. N x 1 x 3의 차원을 갖는다.
  • _features_rest는 SH함수의 1,2차 계수를 저장하기 위한 텐서이다. N x 8 x 3의 차원을 갖는다.
  • dist_tensor는 이전에 구해놓은 knn을 결과인 거리를 저장한다. 
  • _scaling에는 타원체의 scale정보를 저장한다. N x 3의 차원을 갖는다.
    스케일은 항상 양수여야하기 때문에 이 값에 $e^x$를 적용할 것이다.
    실제 저장 값은 $x$에 해당한다.
  • _rotation에는 회전정보, 쿼터니언이 저장된다. N x 4의 차원을 갖는다.
    이때 rots[:,0]=1 의 의미는 두번째 차원의 0번째 인덱스들에 대해 1을 설정한다는 의미로, (1,0,0,0)으로 초기화하는 것을 의미한다.
  • _opacity는 투명도를 저장한다. 투명도는 0~1사이의 값이여야하기 때문에 시그모이드 함수를 사용할 것이다.
    이 때 실제로 저장되는 값은 시그모이드에 입력으로 들어가는 수를 저장하게 된다.
    -2.19를 넣은 것은 sigmoid(-2.19)가 대략 0.1인데, 투명도를 0.1로 초기화하려는 목적이기 때문이다.
    def create_from_pcd(self, points3d_dict, knn_distances):
        print("가우시안 초기화")
        
        xyz = np.array([p['xyz'] for p in points3d_dict.values()])
        rgb = np.array([p['rgb'] for p in points3d_dict.values()]) / 255.0
        

        self._xyz = nn.Parameter(torch.tensor(xyz, dtype=torch.float32).cuda())
        self._features_dc = nn.Parameter(torch.tensor(rgb, dtype=torch.float32).unsqueeze(1).cuda())
        f_rest = torch.zeros((xyz.shape[0], 8, 3), dtype=torch.float32, device="cuda")
        self._features_rest = nn.Parameter(f_rest)

        dist_tensor = torch.tensor(knn_distances, dtype=torch.float32).cuda().unsqueeze(1)
        self._scaling = nn.Parameter(torch.log(dist_tensor.repeat(1, 3)))
        
    
        rots = torch.zeros((xyz.shape[0], 4), device="cuda")
        rots[:, 0] = 1
        self._rotation = nn.Parameter(rots)
        
        
        self._opacity = nn.Parameter(torch.full((xyz.shape[0], 1), -2.19, device="cuda"))

        print(f"{xyz.shape[0]}개의 가우시안 초기화됨")

 

아래의 save_ply함수는 학습 중간에 파라미터들을 저장하기 위한 함수이다.

큰 흐름은 

1. GPU 데이터를 CPU로 가져오고,

2. 파일 형식 및 데이터 타입을 맞춰주고,

3. 데이터를 담아,

4. 바이너리 파일로 저장하는 것이다.

 

이때 파일 형식을 맞추는 것을 엄격하게 생각을 해야한다.

원래는 SH함수를 3차까지 사용하기 때문에 포맷이 이에 맞춰져 있는데, 따라서 3차항까지의 계수가 저장될 공간을 만들어놔야한다.

또 파일형식은 기본적으로 2차원 테이블 형식이라고 생각할 수 있는데, 3차원 이상의 행렬이 있다면 2차원으로 reshape 해줘야한다.

 

  • 일단 가우시안의 중심 좌표들을 GPU에서 데이터를 내리고 CPU로 옮긴 다음 numpy형식으로 변경해준다.
  • normals는 가우시안이 아닌 면을 사용해 모델을 표현하는 다른 방식들에서 법선 벡터를 의미하는데, 여기서는 필요없다.
  • f_dc는 (N,1,3)차원이었는데 이를 (N,3)으로 reshape해주었다.
  • 학습이 되고 있었을 SH함수의 2차항까지의 계수는 역시 GPU에서 내려 저장한다.
    이때도 (N,8.3)에서 (N,24)로 reshape이 필요하다.
  • 3차항은 7개의 계수가 있고 각각이 3개의 숫자를 가지고 있으므로 총 21개의 0을 뒤에 붙여주어야한다.
    아래 코드에서는 f_rest_dummy로 표현되었다.
  • 이 둘을 연결해 f_rest라는 변수에 저장했다.

dtype_full을 통해 헤더에 들어갈 내용을 작성한다.

전에 잠깐 전체 파일의 구조를 알아보자.

헤더바디로 구성되어있다.

바디에서는 각 줄에 하나씩 가우시안의 62개의 정보가 기록된다.

헤더는 이 각각의 줄이 어떤 형식으로 정보를 담고있는지 알려준다.

'각 줄은 x,y,z,nx,ny,nz...의 순서로 정보를 담고 있다' 와 같은 정보이다.

    def save_ply(self, path):

        os.makedirs(os.path.dirname(path), exist_ok=True)
        
        xyz = self._xyz.detach().cpu().numpy()
        normals = np.zeros_like(xyz)
        f_dc = self._features_dc.detach().cpu().numpy().reshape(-1, 3) 
        f_rest_learned = self._features_rest.detach().cpu().numpy().reshape(-1, 24)
        f_rest_dummy = np.zeros((xyz.shape[0], 21))
        f_rest = np.concatenate([f_rest_learned, f_rest_dummy], axis=1)

        opacity = self._opacity.detach().cpu().numpy()
        scale = self._scaling.detach().cpu().numpy()
        rotation = self._rotation.detach().cpu().numpy()


        dtype_full = [('x', 'f4'), ('y', 'f4'), ('z', 'f4'), ('nx', 'f4'), ('ny', 'f4'), ('nz', 'f4')]
        dtype_full += [(f'f_dc_{i}', 'f4') for i in range(3)]
        dtype_full += [(f'f_rest_{i}', 'f4') for i in range(45)] # 45개 유지
        dtype_full += [('opacity', 'f4')]
        dtype_full += [(f'scale_{i}', 'f4') for i in range(3)]
        dtype_full += [(f'rot_{i}', 'f4') for i in range(4)]

        elements = np.empty(xyz.shape[0], dtype=dtype_full)
        attributes = np.concatenate([xyz, normals, f_dc, f_rest, opacity, scale, rotation], axis=1)
        elements[:] = list(map(tuple, attributes))


        el = PlyElement.describe(elements, 'vertex')
        PlyData([el], text=False).write(path)
        
        print(f"Binary PLY 저장: {path}")

 

아래는 실행될 코드이다.

if __name__ == "__main__":
    from points3d_bin_parser import Point3DLoader
    import knn

    object_name="bicycle"


    loader = Point3DLoader("./360_v2/"+object_name+"/sparse/0/points3D.bin")
    points = loader.load()
    initial_scales = knn.compute_initial_scaling(points)


    model = GaussianModel().cuda()
    model.create_from_pcd(points, initial_scales)


    output_path = "./"+object_name+"_output/point_cloud/iteration_0/point_cloud.ply"
    model.save_ply(output_path)

 

 

추가로 config.py파일이란 것을 만들었는데,

항상 bicycle만 가지고 학습할 것은 아니기 때문에

이미지셋을 바꾸더라도 모든 파일에서 하드코딩된 경로를 건들지 말고 

 config만 바꿀 수 있게 했다.

# config.py
import os

OBJECT_NAME = "bicycle" 

IMAGE_PATH = f"./360_v2/{OBJECT_NAME}"
OUTPUT_PATH = f"./360_v2/{OBJECT_NAME}_output"
CAMERA_JSON_PATH = OUTPUT_PATH+"cameras.json"

CAMERAS_BIN_PATH=f"./360_v2/{OBJECT_NAME}/sparse/0/cameras.bin"
POINTS3D_BIN_PATH=f"./360_v2/{OBJECT_NAME}/sparse/0/points3D.bin"
IMAGES_BIN_PATH=f"./360_v2/{OBJECT_NAME}/sparse/0/images.bin"

 

이렇게 하면 다른 파일에서 import config를 한 후 config.IMAGE_PATH와 같이 편하게 사용할 수 있다.

 

최종 폴더 구조는 아래와 같다.

gaussian-splatting은 원 저자들의 코드를 다운받아놓은 것인데(여기서 사용한 것)

따라서 현재 진행하는 직접 구현하기 와는 관련없는 폴더이므로 무시해도 된다.

이제 드디어 gaussianModel.py를 실행시켜보자.

이때 config 파일에서 OBJECT_NAME을 bonsai로 설정 후 실행시켜보았다.

bonsai_output파일이 정상적으로 생성된 것을 볼 수 있다.

아래는 실행 시 터미널의 모습이다.

 

이제 생성된 ply파일을 SIBR뷰어로 봐보자.

 

이전의 3DGS 학습시켜보기 이 글을 보고 따라했었다면 SIBR뷰어가 정상적으로 인식할 수 있는 output폴더가 만들어졌을 것이다.

우리의 코드는 ply파일만 만들 뿐 output폴더의 모든 구조를 따라하는 것이 아니다.

따라서 정상적으로 생성되었던 output파일에서 ply파일만 우리의 파일로 슬쩍 바꿔치기하자.

 

나의 경우는 원격 리눅스 서버에서 코드를 돌리기 때문에 뷰어를 사용하기 위해 데스크탑으로 ply파일만 복사해오는 방식을 사용한다.

어쨋든 돌려보면 아래처럼 나온다.

이 상태에서는 뭐가 뭔지 알아볼 수 없는데 SIBR뷰어에서 scaling modifier를 0.37정도로 바꿔주고 시점을 조금 움직여주면, 오른쪽 사진처럼 나온다.

참고로 SIBR뷰어는 q,w,e,a,s,d / u,i,o,j,k,l를 사용해 조작한다.

직접 한번씩 눌러보면 뭐를 조절하는지 알 수 있을 것이다.

추가로 uiojkl가 안먹힌다면 space를 눌러보고 다시 시도해보자.

 

아래의 원본사진과 비교해보면 아직 학습 시작 전인데도 꽤 모양이 잡혀있는 것을 볼 수 있다.