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

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

CSE 2026. 3. 13. 13:36

 

 

 

COLMAP의 출력 파일의 종류

COLMAP을 돌리면 그 결과가 바이너리 파일로 나온다.

총 3개의 파일이 생성된다.

 

1. cameras.bin

2. images.bin

3. points3D.bin

 

이 파일들을 얻는 법은 이전 글을 참고하면 된다.

 

 

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

↓ ↓ 이전 글 ↓ ↓ [PyTorch] 3DGS 구현 1 - 환경 세팅이전 글 중 3DGS라는 3D분야의 혁신적인 논문을 리뷰한 글이 있다.2026.02.24 - [AI/컴퓨터비전(CV)] - [CV 논문] 3D Gaussian Splatting for Real-Time Radiance Field Re

april2901.tistory.com

 


cameras.bin 파일의 구조

이 파일의 내용은 헤더와 바디로 이루어져 있다.

헤더는 이 데이터셋의 총 카메라 수를 알려주는 가장 처음의 8바이트를 의미한다.

아래 그림은 이전글에서 한번 언급한 Hex Editor를 통해 본 bin파일이다.

참고로 little endian을 사용한다.

바디는

Camera_ID, Model, Width, Height, Param배열

로 이루어져 있다.

 

위 그림을 해석해보자.

 

1 : 방금 설명한 헤드이다. 총 카메라의 수를 얘기한다.

이 데이터셋은 거의 200개의 다른 방향에서 찍은 자전거 이미지의 폴더이다.

그런데 카메라 개수가 1개인 이유는 같은 설정의 카메라는 같은 카메라로 처리하기 때문이다.

즉 이 수치는 찍은 이미지의 수와 같지 않다.

 

 

1 : 카메라의 ID이다. 각 이미지 한장 마다 붙는 ID라고 생각하면 좋다.

 

1 : 카메라 모델이다. 기본적으로 PINHOLE이 사용된다고 생각하면 된다.

실제 저장은 1로 저장되고 1이 PINHOLE모델을 의미한다. 다른 숫자라면 그에 맞는 다른 모델이 선택된 것이다.

 

4946 3286 : 이미지의 가로,세로의 픽셀 해상도를 얘기한다.

 

4649.505.. 4627.3003.. : 초점거리를 얘기한다. 초점거리는 카메라 렌즈의 광학 중심점에서 센서 평면까지의 수직거리를 얘기한다.

이 거리를 픽셀로 변환한 값이다.

값이 2개인데 각각 fx, fy를 의미한다.

계산은

fx : 초점거리(mm) x 가로해상도(pixel) / 센서가로크기(mm)

fy : 초점거리(mm) x 세로해상도(pixel) / 센서세로크기(mm)

 

그런데 보통 이미지는 센서의 비율대로 찍히므로 fx = fy 가 된다.

만약 16:9해상도로 찍었는데 센서가 4:3비율이라도 카메라 소프트웨어에서 잘라내고 16:9로 만들어버리기때문에 특수한 경우가 아니면 두 값은 같다.

정확히 같지 않은 이유는 오차 때문이다.

 

2473 1643 : 방금 얘기한 광학 중심점에서 센서 평면에 내린 수선의 발의 위치를 얘기한다.

이미지의 좌측 상단을 (0,0)으로 기준잡고 이 수선의 발의 좌표를 의미하는 것이다.

해상도의 크기를 봤을 때 정확히 이미지 중앙에 수선의 발이 내려졌다는 것을 확인할 수 있다.

 

이 분석을 해주는 파싱 코드를 파이썬으로 만들면 아래와 같다.

import struct
import os

class CameraLoader:
    def __init__(self, path):
        self.path = path
        self.cameras = {}

    def load(self):
        if not os.path.exists(self.path):
            print(f"{self.path} 파일을 찾을 수 없습니다.")
            return {}

        with open(self.path, "rb") as fid:
            #전체 카메라 개수 (8B)
            num_cameras = struct.unpack("<Q", fid.read(8))[0]
            
            for _ in range(num_cameras):
                # 고정 헤더 파싱 (ID:4, Model:4, W:8, H:8) = 24B
                camera_id, model_id, width, height = struct.unpack("<iiQQ", fid.read(24))
                
                # 파라미터 개수 결정 (PINHOLE 모델 기준 fx, fy, cx, cy)
                params = struct.unpack("<dddd", fid.read(32))
                
                self.cameras[camera_id] = {
                    "model_id": model_id,
                    "width": width,
                    "height": height,
                    "params": params
                }
        
        print(f"카메라 모델 {len(self.cameras)}개 로드 완료.")
        return self.cameras

path = "./360_v2/bicycle/sparse/0/cameras.bin"
loader = CameraLoader(path)
cameras = loader.load()
print(cameras)

 

아래는 실행시 출력 결과이다.


images.bin 파일의 구조

이 파일은 회전, 위치 정보를 가지고 있다.

월드 좌표계에 있는 점을 카메라 기준 좌표계로 바꾸는 회전,위치 정보이다.

 

이 파일은 cameras.bin이 짧았던 것에 비해 엄청 길다.

파일의 초반 부분

구조는 어차피 반복이니 1cycle의 반복 만큼의 분석만 해보자.

 

위 이미지처럼 구조는 되어있다.

정리하면 아래와 같다.

 

여기서 2D점이 많기 때문에 파일의 길이가 길다.

2D점이라는 것은 각 이미지 파일에서의 특징점의 좌표를 나타낸다.

이 각 점의 x,y좌표에 이어 point 3D id라는 것이 나오는데, 이는 3D모델에서 해당 점이 있을 경우 그 점의 id를 얘기한다.

하지만 대부분의 점이 point 3D id가 FFFF..으로 처리되어있다.

이는 -1을 의미하고 3D상에 모델링 되는데 실패한 점들이다.

여러 2D이미지에서 각 점이 공통으로 발견이 되어야 3D 모델에 포함될 수 있는데 그렇지 못했다는 얘기이다.

 

여기서 FFFF...가 아닌 3D상에서 살아남은 점들이 최종적으로 3DGS모델에 제공되고 이 점들이 가우시안의 초기화 시작점이 된다.

그렇다면 왜 이 쓸데없는 실패한 점들을 파일에 넣어놓을까?

이후에 예를 들어 같은 물체를 다른 각도에서 찍은 이미지가 50장 정도씩 추가되어 이 점들이 여러 이미지에서 나오게 되면 살아남을 수 있기 때문이다.

다 지워놓고 데이터 추가시에 COLMAP을 다시 돌리는 것은 비효율적이다.

 

하지만 데이터를 추가할 생각이 없다면?

그래서 실제로 이 점들은 모두 버리고 살아남은 점들만 따로 모아 학습에 사용한다.

이 과정이 3DGS를 직접 구현할 때 데이터 전처리를 위해 해야하는 목표라고도 할 수 있을 것이다.

 

파싱하는 코드는 아래처럼 구성한다.

import struct
import os

class ImageLoader:
    def __init__(self, path):
        self.path = path
        self.images = {}

    def load(self):
        if not os.path.exists(self.path):
            print(f" {self.path} 파일 없음.")
            return {}

        images = {}
        with open(self.path, "rb") as fid:
            
            num_reg_images = struct.unpack("<Q", fid.read(8))[0] #전체 이미지 수
            print(f"총 등록된 이미지 개수: {num_reg_images}")

            for _ in range(num_reg_images):
                #ID:4, Q:32, T:24, CamID:4
                binary_header = fid.read(64)
                data = struct.unpack("<idddddddi", binary_header)
                
                image_id = data[0]
                qvec = (data[1], data[2], data[3], data[4]) # qw, qx, qy, qz
                tvec = (data[5], data[6], data[7])          # tx, ty, tz
                camera_id = data[8]

                #파일 이름 읽기, 널까지
                image_name = ""
                while True:
                    char = fid.read(1).decode("utf-8")
                    if char == "\0":
                        break
                    image_name += char

                # 2d 점 부분 뛰어넘기
                # 각 점은 (x, y, point3D_id)
                num_points2d = struct.unpack("<Q", fid.read(8))[0]
                fid.read(num_points2d * 24)

                self.images[image_id] = { #필요할 것만 저장
                    "name": image_name,
                    "qvec": qvec,
                    "tvec": tvec,
                    "camera_id": camera_id
                }


        return self.images


path = "./360_v2/bicycle/sparse/0/images.bin"
loader = ImageLoader(path)
loader.load()

points3D.bin 파일의 구조

이 파일은 3D점의 정보를 가지고 있다.

images.bin파일에서 2D점 정보 중 point 3D id 부분이 FFFF..으로 되어있던 점들은 모두 제외되고, 이 부분에 정상적인 id가 있는 점들의 정보가 모여있다.

 

구조를 보자.

처음 head로 파일에 담긴 전체 점의 개수가 써져있다.

이후 이 전체 점에 대해 하나씩 정보가 나온다.

먼저 점의 id가 나오고, 그 점의 x,y,z좌표가 이어 나온다.

이후 중간의 파란색 네모로 표시된 부분은 RGB값을 나타낸다.

ERROR는 이 3D점을 다시 2D로 projection했을 때 원래 위치와 얼마나 차이가 나는지를 나타낸 오차 수치이다.

이 수치는 나중에 너무 크면 모델링에서 제외하는 식의 옵션을 주는 방식으로 사용될 수 있다.

Track len이라는 것은 몇개의 이미지가 연동되어 이 3D점을 만들었는지 그 이미지 장 수를 얘기한다.

이 경우 D이므로 13개의 이미지가 사용되었다.

이어서 (4바이트, 4바이트)의 구성으로 13개의 점에 대한 정보가 나온다.

이 정보는 이 13개의 점이 2d 이미지의 어떤 점인지를 알려주는 (이미지 id, 2d point id)구성이다.

이후 이 흐름이 반복된다.

 

코드이다.

import struct

class Point3DLoader:
    def __init__(self, path):
        self.path = path
        self.points = {}

    def load(self):
        with open(self.path, "rb") as fid:
            # 전체 점 개수
            num_points = struct.unpack("<Q", fid.read(8))[0]
            
            for _ in range(num_points):
                # 고정 43바이트 읽기
                binary_data = fid.read(43)
                p_id, x, y, z, r, g, b, error = struct.unpack("<QdddBBBd", binary_data)
                
                track_len = struct.unpack("<Q", fid.read(8))[0]
                fid.read(track_len * 8) 
                
                self.points[p_id] = {
                    "xyz": (x, y, z),
                    "rgb": (r, g, b),
                    "error": error
                }
                
        return self.points

path = "./360_v2/bicycle/sparse/0/points3D.bin"
loader = Point3DLoader(path)
points = loader.load()

 


간단 시각화

이렇게 코드를 짜서 점 정보를 추출했으니 이 점을 실제로 찍어보고 3d툴을 통해 살펴보자.

 

여러 방법이 있지만 나는 가장 빠른 방법을 선호했기 때문에 python내부에서 바로 보는 open3d를 사용했다.

설치는 간단하다.

pip install open3d

위처럼 설치 후 아래의 코드를 통해 바로 시각화할 수 있다.

from points3d_bin_parser import Point3DLoader
import open3d as o3d
import numpy as np


path = "./360_v2/bicycle/sparse/0/points3D.bin"
loader = Point3DLoader(path)
points = loader.load()


xyz = np.array([p['xyz'] for p in points.values()])
rgb = np.array([p['rgb'] for p in points.values()]) / 255.0 # 0~1 범위

pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(xyz)
pcd.colors = o3d.utility.Vector3dVector(rgb)

o3d.visualization.draw_geometries([pcd])

 

이 코드를 실행시키면 창이 하나 뜨고 아래와 같은 모습이 나온다.

마우스 드래그로 이리저리 돌려볼 수 있다.

 

아래는 원본 이미지들 중 몇개를 가져온 것이다.

아직은 COLMAP으로 추출된 점만 찍힌 것이기 때문에 자전거라는 것을 알아보기 힘들 정도의 퀄리티이다.

이제 이 점들을 씨앗으로 삼아 가우시안을 만들고 학습시켜 점차 원본 이미지에 가까워지는지 마저 실습을 통해 확인해보자.