File size: 11,207 Bytes
dc09dcc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
"""Convert existing calibration data into SplatAnything's camera JSON convention."""
from pathlib import Path
import os
import struct

import numpy as np
from PIL import Image

from src.utils.camera_io import validate_pose, validate_intrinsics
from src.utils.geometry import colmap_to_opencv_intrinsics


def to_opencv_c2w(matrix, pose_convention, camera_axes):
    """Invert w2c first, then convert camera axes while preserving the world frame."""
    matrix = np.asarray(matrix, dtype=np.float64)
    if matrix.shape == (3, 4):
        matrix = np.vstack([matrix, [0, 0, 0, 1]])
    validate_pose(matrix, 'source')
    if pose_convention not in {'c2w', 'w2c'} or camera_axes not in {'opencv', 'opengl'}:
        raise ValueError('Specify c2w/w2c and opencv/opengl explicitly.')
    result = np.linalg.inv(matrix) if pose_convention == 'w2c' else matrix.copy()
    if camera_axes == 'opengl':
        result = result @ np.diag([1., -1., -1., 1.])
    return result


def _store(records, name, pose, intrinsic, image_size=None):
    # Inference takes a flat image directory. Never collapse ambiguous source names silently.
    basename = Path(name).name
    stem = Path(basename).stem
    if basename in records or any(Path(existing).stem == stem for existing in records):
        raise ValueError(f'Ambiguous or duplicate source image name: {name!r}. '
                         'Each image needs a unique filename and stem.')
    records[basename] = (pose, intrinsic, image_size)


def _match_records(images, records):
    missing = [p.name for p in images if p.name not in records]
    if missing:
        raise ValueError(f'{len(missing)} input images have no calibration: '
                         f'{", ".join(missing[:5])}. Keep only calibrated/registered images '
                         'in the input folder and preserve their filenames.')
    poses, intrinsics = {}, {}
    for path in images:
        pose, intrinsic, expected_size = records[path.name]
        if expected_size is not None:
            with Image.open(path) as image:
                actual_size = image.size
            if actual_size != expected_size:
                raise ValueError(f'{path.name}: image size {actual_size} differs from calibration '
                                 f'size {expected_size}. Use the matching undistorted images/model; '
                                 'do not mix original and resized calibration data.')
        poses[path.stem] = pose
        intrinsics[path.stem] = intrinsic
    return poses, intrinsics


def cameras_from_arrays(images, poses_path, intrinsics_path, image_names_path,
                        pose_convention, camera_axes, intrinsics_pixel_center='opencv'):
    """Match matrix rows to explicit image names; a single K may be shared by all views."""
    names = [line.strip() for line in Path(image_names_path).read_text(encoding='utf-8').splitlines()
             if line.strip()]
    poses = np.load(poses_path, allow_pickle=False)
    intrinsics = np.load(intrinsics_path, allow_pickle=False)
    if not isinstance(poses, np.ndarray) or not isinstance(intrinsics, np.ndarray):
        raise ValueError('Use numeric .npy arrays, not .npz archives or object arrays.')
    if poses.ndim == 2:
        poses = poses[None]
    if poses.ndim != 3 or poses.shape[1:] not in {(4, 4), (3, 4)}:
        raise ValueError('Poses must have shape [N, 4, 4] or [N, 3, 4].')
    if len(names) != len(poses):
        raise ValueError(f'image_names.txt has {len(names)} names but poses has {len(poses)} rows.')
    if intrinsics.shape == (3, 3):
        intrinsics = np.broadcast_to(intrinsics, (len(poses), 3, 3))
    if intrinsics.shape != (len(poses), 3, 3):
        raise ValueError('Intrinsics must have shape [3, 3] or [N, 3, 3], in original-image pixels.')
    if intrinsics_pixel_center not in {'opencv', 'colmap'}:
        raise ValueError('Intrinsics pixel center must be opencv or colmap.')
    records = {}
    for name, pose, intrinsic in zip(names, poses, intrinsics):
        intrinsic = np.asarray(intrinsic, dtype=np.float64).copy()
        if intrinsics_pixel_center == 'colmap':
            intrinsic = colmap_to_opencv_intrinsics(intrinsic)
        validate_intrinsics(intrinsic, name)
        _store(records, name, to_opencv_c2w(pose, pose_convention, camera_axes), intrinsic)
    return _match_records(images, records)


def _pinhole_camera(model, width, height, params):
    if model == 'SIMPLE_PINHOLE' and len(params) == 3:
        fx, cx, cy = params
        fy = fx
    elif model == 'PINHOLE' and len(params) == 4:
        fx, fy, cx, cy = params
    else:
        raise ValueError(f'Unsupported COLMAP camera model/parameters: {model}. '
                         'Run COLMAP image_undistorter and use its PINHOLE or SIMPLE_PINHOLE '
                         'model together with the undistorted images.')
    if width <= 0 or height <= 0:
        raise ValueError('COLMAP camera image dimensions must be positive.')
    matrix = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtype=np.float64)
    # COLMAP locates the first pixel center at (0.5, 0.5); inference uses (0, 0).
    matrix = colmap_to_opencv_intrinsics(matrix)
    validate_intrinsics(matrix, 'COLMAP')
    return matrix, (width, height)


def _pose_from_colmap(qvec, tvec):
    """Convert COLMAP's Hamilton WXYZ quaternion and w2c translation to c2w."""
    qvec = np.asarray(qvec, dtype=np.float64)
    norm = np.linalg.norm(qvec)
    if not np.isfinite(norm) or norm < 1e-12:
        raise ValueError('COLMAP pose has an invalid quaternion.')
    w, x, y, z = qvec / norm
    rotation = np.array([
        [1-2*(y*y+z*z), 2*(x*y-z*w), 2*(x*z+y*w)],
        [2*(x*y+z*w), 1-2*(x*x+z*z), 2*(y*z-x*w)],
        [2*(x*z-y*w), 2*(y*z+x*w), 1-2*(x*x+y*y)],
    ])
    matrix = np.eye(4)
    matrix[:3, :3] = rotation.T
    matrix[:3, 3] = -rotation.T @ np.asarray(tvec, dtype=np.float64)
    validate_pose(matrix, 'COLMAP')
    return matrix


def _read_text_model(model_dir):
    cameras, records, image_ids = {}, {}, set()
    for line in (model_dir / 'cameras.txt').read_text(encoding='utf-8').splitlines():
        if not line.strip() or line.lstrip().startswith('#'):
            continue
        parts = line.split()
        if len(parts) < 7:
            raise ValueError('Malformed cameras.txt camera line.')
        camera_id = int(parts[0])
        if camera_id in cameras:
            raise ValueError(f'Duplicate COLMAP camera ID: {camera_id}')
        cameras[camera_id] = _pinhole_camera(parts[1], int(parts[2]), int(parts[3]),
                                             [float(value) for value in parts[4:]])
    with (model_dir / 'images.txt').open(encoding='utf-8') as handle:
        for line in handle:
            if not line.strip() or line.lstrip().startswith('#'):
                continue
            parts = line.strip().split(maxsplit=9)
            if len(parts) != 10:
                raise ValueError('Malformed images.txt image header.')
            image_id = int(parts[0])
            if image_id in image_ids:
                raise ValueError(f'Duplicate COLMAP image ID: {image_id}')
            image_ids.add(image_id)
            camera_id = int(parts[8])
            if camera_id not in cameras:
                raise ValueError(f'COLMAP image {parts[9]} refers to missing camera {camera_id}.')
            matrix, size = cameras[camera_id]
            pose = _pose_from_colmap([float(v) for v in parts[1:5]], [float(v) for v in parts[5:8]])
            _store(records, parts[9], pose, matrix, size)
            points_line = handle.readline()
            if not points_line or len(points_line.split()) % 3:
                raise ValueError('Each images.txt image header must be followed by a POINTS2D line '
                                 '(an empty line is valid for zero observations).')
    return records


def _unpack(handle, layout):
    size = struct.calcsize('<' + layout)
    data = handle.read(size)
    if len(data) != size:
        raise ValueError(f'Truncated COLMAP binary file: {handle.name}')
    return struct.unpack('<' + layout, data)


def _read_binary_model(model_dir):
    cameras, records, image_ids = {}, {}, set()
    with (model_dir / 'cameras.bin').open('rb') as handle:
        count, = _unpack(handle, 'Q')
        for _ in range(count):
            camera_id, model_id, width, height = _unpack(handle, 'iiQQ')
            if model_id not in {0, 1}:
                raise ValueError(f'Unsupported COLMAP binary camera model ID {model_id}. '
                                 'Use image_undistorter to export a pinhole model and matching images.')
            if camera_id in cameras:
                raise ValueError(f'Duplicate COLMAP camera ID: {camera_id}')
            model, num_params = ('SIMPLE_PINHOLE', 3) if model_id == 0 else ('PINHOLE', 4)
            cameras[camera_id] = _pinhole_camera(model, width, height, _unpack(handle, 'd'*num_params))
    with (model_dir / 'images.bin').open('rb') as handle:
        count, = _unpack(handle, 'Q')
        for _ in range(count):
            values = _unpack(handle, 'i7di')
            image_id, camera_id = values[0], values[-1]
            if image_id in image_ids:
                raise ValueError(f'Duplicate COLMAP image ID: {image_id}')
            image_ids.add(image_id)
            name = bytearray()
            while True:
                char = handle.read(1)
                if not char:
                    raise ValueError('Truncated COLMAP image name.')
                if char == b'\0':
                    break
                name.extend(char)
            name = name.decode('utf-8')
            if camera_id not in cameras:
                raise ValueError(f'COLMAP image {name} refers to missing camera {camera_id}.')
            matrix, size = cameras[camera_id]
            _store(records, name, _pose_from_colmap(values[1:5], values[5:8]), matrix, size)
            points, = _unpack(handle, 'Q')
            end = handle.tell() + points * 24
            if end > os.fstat(handle.fileno()).st_size:
                raise ValueError(f'Truncated COLMAP point observations for image {name}.')
            handle.seek(end)
    return records


def cameras_from_colmap(images, model_dir, model_format='auto'):
    """Read the cameras/images pair only; points3D, pycolmap, and GPU libraries are not needed."""
    model_dir = Path(model_dir)
    if model_format not in {'auto', 'bin', 'txt'}:
        raise ValueError('COLMAP model format must be auto, bin, or txt.')
    if model_format == 'auto':
        model_format = next((ext for ext in ('bin', 'txt') if all(
            (model_dir / f'{name}.{ext}').is_file() for name in ('cameras', 'images'))), None)
    if model_format is None or not all((model_dir / f'{name}.{model_format}').is_file()
                                       for name in ('cameras', 'images')):
        raise ValueError(f'No matching cameras/images .bin or .txt pair found in {model_dir}. '
                         'Point --model at one sparse reconstruction, e.g. sparse/0.')
    records = _read_binary_model(model_dir) if model_format == 'bin' else _read_text_model(model_dir)
    return _match_records(images, records)