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으로 추출된 점만 찍힌 것이기 때문에 자전거라는 것을 알아보기 힘들 정도의 퀄리티이다.
이제 이 점들을 씨앗으로 삼아 가우시안을 만들고 학습시켜 점차 원본 이미지에 가까워지는지 마저 실습을 통해 확인해보자.



'프로젝트, 연구 > 3DGS 구현' 카테고리의 다른 글
| [3DGS 구현] 6. 이미지 전처리 및 GPU로딩 (0) | 2026.03.24 |
|---|---|
| [3DGS 구현] 5. gaussian 초기화 및 SIBR뷰어를 통한 시각화 (0) | 2026.03.23 |
| [3DGS 구현] 4. 가우시안의 초기 scale계산 및 카메라 데이터 json 변환 (0) | 2026.03.16 |
| [3DGS 구현] 2. Kaggle에서 데이터셋 다운받기 (5) | 2026.03.12 |
| [3DGS 구현] 1. 환경 세팅 (3) | 2026.03.04 |