colmap4d 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- colmap4d/__init__.py +57 -0
- colmap4d/colmap_io.py +261 -0
- colmap4d/convert/__init__.py +18 -0
- colmap4d/convert/per_frame_colmap.py +233 -0
- colmap4d/groups.py +14 -0
- colmap4d/model.py +111 -0
- colmap4d/sidecar.py +255 -0
- colmap4d/validate.py +184 -0
- colmap4d-1.0.1.dist-info/METADATA +138 -0
- colmap4d-1.0.1.dist-info/RECORD +13 -0
- colmap4d-1.0.1.dist-info/WHEEL +5 -0
- colmap4d-1.0.1.dist-info/licenses/LICENSE +202 -0
- colmap4d-1.0.1.dist-info/top_level.txt +1 -0
colmap4d/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""colmap4d — standard COLMAP sparse model + per-image and per-point timestamps.
|
|
2
|
+
|
|
3
|
+
The zero-dependency time layer lives in :mod:`colmap4d.sidecar`. Base-model parsing
|
|
4
|
+
(cameras/images/points3D) is delegated to pycolmap in :mod:`colmap4d.model` (optional
|
|
5
|
+
dependency, not implemented yet).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from colmap4d.model import (
|
|
11
|
+
ModelView,
|
|
12
|
+
load_model_view,
|
|
13
|
+
read_base_model,
|
|
14
|
+
validate_full,
|
|
15
|
+
)
|
|
16
|
+
from colmap4d.sidecar import (
|
|
17
|
+
TIMELESS,
|
|
18
|
+
Sidecars,
|
|
19
|
+
is_temporally_unbounded,
|
|
20
|
+
load_sidecars,
|
|
21
|
+
read_points_t_bin,
|
|
22
|
+
read_points_t_txt,
|
|
23
|
+
read_time_meta,
|
|
24
|
+
read_times_bin,
|
|
25
|
+
read_times_txt,
|
|
26
|
+
rebase_to_seconds_f32,
|
|
27
|
+
write_points_t_bin,
|
|
28
|
+
write_points_t_txt,
|
|
29
|
+
write_time_meta,
|
|
30
|
+
write_times_bin,
|
|
31
|
+
write_times_txt,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__version__ = "1.0.1"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"TIMELESS",
|
|
38
|
+
"ModelView",
|
|
39
|
+
"Sidecars",
|
|
40
|
+
"is_temporally_unbounded",
|
|
41
|
+
"load_model_view",
|
|
42
|
+
"load_sidecars",
|
|
43
|
+
"read_base_model",
|
|
44
|
+
"validate_full",
|
|
45
|
+
"rebase_to_seconds_f32",
|
|
46
|
+
"read_points_t_bin",
|
|
47
|
+
"read_points_t_txt",
|
|
48
|
+
"read_time_meta",
|
|
49
|
+
"read_times_bin",
|
|
50
|
+
"read_times_txt",
|
|
51
|
+
"write_points_t_bin",
|
|
52
|
+
"write_points_t_txt",
|
|
53
|
+
"write_time_meta",
|
|
54
|
+
"write_times_bin",
|
|
55
|
+
"write_times_txt",
|
|
56
|
+
"__version__",
|
|
57
|
+
]
|
colmap4d/colmap_io.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Zero-dependency reader/writer for the COLMAP base model (cameras/images/points3D).
|
|
2
|
+
|
|
3
|
+
This is the pure-Python fallback so ``import colmap4d`` can read a timestamped model with no
|
|
4
|
+
compiled dependency. pycolmap remains the *preferred* backend where installed (it is
|
|
5
|
+
authoritative and also understands the 3.12 rigs/frames binary variants); this reader targets
|
|
6
|
+
the classic cameras/images/points3D text and binary layouts, which cover the vast majority of
|
|
7
|
+
models in the wild and every model colmap4d itself writes.
|
|
8
|
+
|
|
9
|
+
Binary layout follows COLMAP's ``reconstruction_io`` (little-endian, count-prefixed):
|
|
10
|
+
cameras.bin : u64 count, then [u32 id, i32 model_id, u64 w, u64 h, f64 params[k]]
|
|
11
|
+
images.bin : u64 count, then [u32 id, f64 qw qx qy qz, f64 tx ty tz, u32 cam_id,
|
|
12
|
+
name\\0, u64 n2d, [f64 x, f64 y, u64 point3D_id] * n2d]
|
|
13
|
+
points3D.bin : u64 count, then [u64 id, f64 x y z, u8 r g b, f64 error,
|
|
14
|
+
u64 track_len, [u32 image_id, u32 point2D_idx] * track_len]
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import struct
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# COLMAP camera model id <-> name and parameter counts.
|
|
24
|
+
_MODEL_ID_TO_NAME = {
|
|
25
|
+
0: "SIMPLE_PINHOLE",
|
|
26
|
+
1: "PINHOLE",
|
|
27
|
+
2: "SIMPLE_RADIAL",
|
|
28
|
+
3: "RADIAL",
|
|
29
|
+
4: "OPENCV",
|
|
30
|
+
5: "OPENCV_FISHEYE",
|
|
31
|
+
6: "FULL_OPENCV",
|
|
32
|
+
7: "FOV",
|
|
33
|
+
8: "SIMPLE_RADIAL_FISHEYE",
|
|
34
|
+
9: "RADIAL_FISHEYE",
|
|
35
|
+
10: "THIN_PRISM_FISHEYE",
|
|
36
|
+
}
|
|
37
|
+
_MODEL_NAME_TO_ID = {v: k for k, v in _MODEL_ID_TO_NAME.items()}
|
|
38
|
+
_MODEL_NUM_PARAMS = {0: 3, 1: 4, 2: 4, 3: 5, 4: 8, 5: 8, 6: 12, 7: 5, 8: 4, 9: 5, 10: 12}
|
|
39
|
+
|
|
40
|
+
# COLMAP's sentinel for "this 2D point has no 3D point".
|
|
41
|
+
INVALID_POINT3D_ID = 18446744073709551615
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Camera:
|
|
46
|
+
id: int
|
|
47
|
+
model: str
|
|
48
|
+
width: int
|
|
49
|
+
height: int
|
|
50
|
+
params: list[float]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Image:
|
|
55
|
+
id: int
|
|
56
|
+
name: str
|
|
57
|
+
camera_id: int
|
|
58
|
+
qvec: list[float] # [qw, qx, qy, qz]
|
|
59
|
+
tvec: list[float] # [tx, ty, tz]
|
|
60
|
+
xys: list[tuple[float, float]] = field(default_factory=list)
|
|
61
|
+
point3D_ids: list[int] = field(default_factory=list) # INVALID_POINT3D_ID where none
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Point3D:
|
|
66
|
+
id: int
|
|
67
|
+
xyz: list[float]
|
|
68
|
+
rgb: list[int]
|
|
69
|
+
error: float
|
|
70
|
+
track: list[tuple[int, int]] # (image_id, point2D_idx)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class BaseModel:
|
|
75
|
+
cameras: dict[int, Camera] = field(default_factory=dict)
|
|
76
|
+
images: dict[int, Image] = field(default_factory=dict)
|
|
77
|
+
points3D: dict[int, Point3D] = field(default_factory=dict)
|
|
78
|
+
|
|
79
|
+
def image_ids(self) -> set[int]:
|
|
80
|
+
return set(self.images)
|
|
81
|
+
|
|
82
|
+
def point_ids(self) -> set[int]:
|
|
83
|
+
return set(self.points3D)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --------------------------------------------------------------------------- #
|
|
87
|
+
# binary helpers
|
|
88
|
+
# --------------------------------------------------------------------------- #
|
|
89
|
+
def _r(f, fmt: str):
|
|
90
|
+
s = struct.Struct("<" + fmt)
|
|
91
|
+
return s.unpack(f.read(s.size))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _w(f, fmt: str, *vals) -> None:
|
|
95
|
+
f.write(struct.pack("<" + fmt, *vals))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --------------------------------------------------------------------------- #
|
|
99
|
+
# text parsing helpers
|
|
100
|
+
# --------------------------------------------------------------------------- #
|
|
101
|
+
def _data_lines(path: str | Path) -> list[str]:
|
|
102
|
+
out = []
|
|
103
|
+
with open(path, encoding="utf-8") as fh:
|
|
104
|
+
for raw in fh:
|
|
105
|
+
line = raw.strip()
|
|
106
|
+
if line and not line.startswith("#"):
|
|
107
|
+
out.append(line)
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --------------------------------------------------------------------------- #
|
|
112
|
+
# cameras
|
|
113
|
+
# --------------------------------------------------------------------------- #
|
|
114
|
+
def read_cameras_txt(path: str | Path) -> dict[int, Camera]:
|
|
115
|
+
cams = {}
|
|
116
|
+
for line in _data_lines(path):
|
|
117
|
+
p = line.split()
|
|
118
|
+
cid = int(p[0])
|
|
119
|
+
cams[cid] = Camera(cid, p[1], int(p[2]), int(p[3]), [float(x) for x in p[4:]])
|
|
120
|
+
return cams
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_cameras_bin(path: str | Path) -> dict[int, Camera]:
|
|
124
|
+
cams = {}
|
|
125
|
+
with open(path, "rb") as f:
|
|
126
|
+
(n,) = _r(f, "Q")
|
|
127
|
+
for _ in range(n):
|
|
128
|
+
cid, model_id, w, h = _r(f, "IiQQ")
|
|
129
|
+
k = _MODEL_NUM_PARAMS[model_id]
|
|
130
|
+
params = list(_r(f, f"{k}d"))
|
|
131
|
+
cams[cid] = Camera(cid, _MODEL_ID_TO_NAME[model_id], w, h, params)
|
|
132
|
+
return cams
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def write_cameras_bin(path: str | Path, cams: dict[int, Camera]) -> None:
|
|
136
|
+
with open(path, "wb") as f:
|
|
137
|
+
_w(f, "Q", len(cams))
|
|
138
|
+
for cid in sorted(cams):
|
|
139
|
+
c = cams[cid]
|
|
140
|
+
_w(f, "IiQQ", cid, _MODEL_NAME_TO_ID[c.model], c.width, c.height)
|
|
141
|
+
_w(f, f"{len(c.params)}d", *c.params)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --------------------------------------------------------------------------- #
|
|
145
|
+
# images
|
|
146
|
+
# --------------------------------------------------------------------------- #
|
|
147
|
+
def read_images_txt(path: str | Path) -> dict[int, Image]:
|
|
148
|
+
lines = _data_lines(path)
|
|
149
|
+
images = {}
|
|
150
|
+
for i in range(0, len(lines), 2):
|
|
151
|
+
head = lines[i].split()
|
|
152
|
+
iid = int(head[0])
|
|
153
|
+
qvec = [float(x) for x in head[1:5]]
|
|
154
|
+
tvec = [float(x) for x in head[5:8]]
|
|
155
|
+
cam_id = int(head[8])
|
|
156
|
+
name = head[9]
|
|
157
|
+
xys, ids = [], []
|
|
158
|
+
pts = lines[i + 1].split() if i + 1 < len(lines) else []
|
|
159
|
+
for j in range(0, len(pts), 3):
|
|
160
|
+
xys.append((float(pts[j]), float(pts[j + 1])))
|
|
161
|
+
pid = int(pts[j + 2])
|
|
162
|
+
ids.append(INVALID_POINT3D_ID if pid < 0 else pid)
|
|
163
|
+
images[iid] = Image(iid, name, cam_id, qvec, tvec, xys, ids)
|
|
164
|
+
return images
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def read_images_bin(path: str | Path) -> dict[int, Image]:
|
|
168
|
+
images = {}
|
|
169
|
+
with open(path, "rb") as f:
|
|
170
|
+
(n,) = _r(f, "Q")
|
|
171
|
+
for _ in range(n):
|
|
172
|
+
iid, qw, qx, qy, qz, tx, ty, tz, cam_id = _r(f, "IdddddddI")
|
|
173
|
+
name = bytearray()
|
|
174
|
+
while (ch := f.read(1)) not in (b"\x00", b""):
|
|
175
|
+
name += ch
|
|
176
|
+
(n2d,) = _r(f, "Q")
|
|
177
|
+
xys, ids = [], []
|
|
178
|
+
for _ in range(n2d):
|
|
179
|
+
x, y, pid = _r(f, "ddQ")
|
|
180
|
+
xys.append((x, y))
|
|
181
|
+
ids.append(pid)
|
|
182
|
+
images[iid] = Image(
|
|
183
|
+
iid, name.decode("utf-8"), cam_id, [qw, qx, qy, qz], [tx, ty, tz], xys, ids
|
|
184
|
+
)
|
|
185
|
+
return images
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def write_images_bin(path: str | Path, images: dict[int, Image]) -> None:
|
|
189
|
+
with open(path, "wb") as f:
|
|
190
|
+
_w(f, "Q", len(images))
|
|
191
|
+
for iid in sorted(images):
|
|
192
|
+
im = images[iid]
|
|
193
|
+
_w(f, "Iddddddd", iid, *im.qvec, *im.tvec)
|
|
194
|
+
_w(f, "I", im.camera_id)
|
|
195
|
+
f.write(im.name.encode("utf-8") + b"\x00")
|
|
196
|
+
_w(f, "Q", len(im.xys))
|
|
197
|
+
for (x, y), pid in zip(im.xys, im.point3D_ids):
|
|
198
|
+
_w(f, "ddQ", x, y, pid)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --------------------------------------------------------------------------- #
|
|
202
|
+
# points3D
|
|
203
|
+
# --------------------------------------------------------------------------- #
|
|
204
|
+
def read_points3D_txt(path: str | Path) -> dict[int, Point3D]:
|
|
205
|
+
pts = {}
|
|
206
|
+
for line in _data_lines(path):
|
|
207
|
+
p = line.split()
|
|
208
|
+
pid = int(p[0])
|
|
209
|
+
xyz = [float(x) for x in p[1:4]]
|
|
210
|
+
rgb = [int(x) for x in p[4:7]]
|
|
211
|
+
error = float(p[7])
|
|
212
|
+
rest = p[8:]
|
|
213
|
+
track = [(int(rest[k]), int(rest[k + 1])) for k in range(0, len(rest), 2)]
|
|
214
|
+
pts[pid] = Point3D(pid, xyz, rgb, error, track)
|
|
215
|
+
return pts
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def read_points3D_bin(path: str | Path) -> dict[int, Point3D]:
|
|
219
|
+
pts = {}
|
|
220
|
+
with open(path, "rb") as f:
|
|
221
|
+
(n,) = _r(f, "Q")
|
|
222
|
+
for _ in range(n):
|
|
223
|
+
pid, x, y, z, r, g, b, error, tl = _r(f, "QdddBBBdQ")
|
|
224
|
+
track = [tuple(_r(f, "II")) for _ in range(tl)]
|
|
225
|
+
pts[pid] = Point3D(pid, [x, y, z], [r, g, b], error, track)
|
|
226
|
+
return pts
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def write_points3D_bin(path: str | Path, pts: dict[int, Point3D]) -> None:
|
|
230
|
+
with open(path, "wb") as f:
|
|
231
|
+
_w(f, "Q", len(pts))
|
|
232
|
+
for pid in sorted(pts):
|
|
233
|
+
p = pts[pid]
|
|
234
|
+
_w(f, "QdddBBBdQ", pid, *p.xyz, *p.rgb, p.error, len(p.track))
|
|
235
|
+
for img_id, idx in p.track:
|
|
236
|
+
_w(f, "II", img_id, idx)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --------------------------------------------------------------------------- #
|
|
240
|
+
# whole model
|
|
241
|
+
# --------------------------------------------------------------------------- #
|
|
242
|
+
def read_model(model_dir: str | Path) -> BaseModel:
|
|
243
|
+
"""Read cameras/images/points3D from a COLMAP model dir. Binary is preferred over text
|
|
244
|
+
when both are present (matching COLMAP)."""
|
|
245
|
+
d = Path(model_dir)
|
|
246
|
+
|
|
247
|
+
def pick(stem: str):
|
|
248
|
+
if (d / f"{stem}.bin").exists():
|
|
249
|
+
return d / f"{stem}.bin", "bin"
|
|
250
|
+
if (d / f"{stem}.txt").exists():
|
|
251
|
+
return d / f"{stem}.txt", "txt"
|
|
252
|
+
raise FileNotFoundError(f"no {stem}.bin or {stem}.txt in {d}")
|
|
253
|
+
|
|
254
|
+
cpath, ck = pick("cameras")
|
|
255
|
+
ipath, ik = pick("images")
|
|
256
|
+
ppath, pk = pick("points3D")
|
|
257
|
+
return BaseModel(
|
|
258
|
+
cameras=read_cameras_bin(cpath) if ck == "bin" else read_cameras_txt(cpath),
|
|
259
|
+
images=read_images_bin(ipath) if ik == "bin" else read_images_txt(ipath),
|
|
260
|
+
points3D=read_points3D_bin(ppath) if pk == "bin" else read_points3D_txt(ppath),
|
|
261
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Converters into colmap4d — the format's "writer" side.
|
|
2
|
+
|
|
3
|
+
One importer per stock data layout, turning already-existing data into colmap4d so the
|
|
4
|
+
format's data "exists" in the world:
|
|
5
|
+
|
|
6
|
+
per_frame_colmap -- N per-frame COLMAP dirs -> one colmap4d model (IMPLEMENTED)
|
|
7
|
+
nerfstudio -- transforms.json (time field), bidirectional (planned)
|
|
8
|
+
neu3d / dynerf -- first-frame COLMAP + video frame indices (planned)
|
|
9
|
+
hypernerf / nerfies (planned)
|
|
10
|
+
|
|
11
|
+
Each importer emits standard COLMAP files plus the three time sidecars.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from colmap4d.convert.per_frame_colmap import convert_per_frame_colmap
|
|
17
|
+
|
|
18
|
+
__all__ = ["convert_per_frame_colmap"]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Convert a per-frame COLMAP capture into a single colmap4d model.
|
|
2
|
+
|
|
3
|
+
Input: an ordered list of per-frame COLMAP sparse models (``colmap_0/…``, ``colmap_1/…``),
|
|
4
|
+
each an independent reconstruction of the *same rig at one time instant*. Output: one standard
|
|
5
|
+
COLMAP model + colmap4d sidecars, where each image and point carries that frame's timestamp.
|
|
6
|
+
|
|
7
|
+
Design choices (each traces to a white-paper principle; the contentious one is flagged):
|
|
8
|
+
|
|
9
|
+
* **Points: per-frame independent, NO cross-frame dedup (default).** White paper §3.3: a
|
|
10
|
+
per-frame import honestly samples a persistent structure as one point *per frame*. Every
|
|
11
|
+
frame's points are kept and remapped to globally-unique ids; each gets its frame's time.
|
|
12
|
+
Cross-frame dedup would fabricate correspondences COLMAP never computed and collapse the
|
|
13
|
+
honest samples into a "conclusion" — it violates "store observations, not conclusions"
|
|
14
|
+
(§2). Per the author (white paper Q3/§3.3, OPEN-6 settled), cross-frame dedup is a derived
|
|
15
|
+
view / downstream optimization and is **out of scope for v1**: ``dedup_points=True`` stays
|
|
16
|
+
reserved and is refused.
|
|
17
|
+
|
|
18
|
+
* **Cameras: per-frame independent, remapped (lossless).** Real captures reassign COLMAP
|
|
19
|
+
``CAMERA_ID`` per frame (one frame may share a single intrinsic across all physical cameras,
|
|
20
|
+
another may give each its own), so intrinsics are kept as-is per frame rather than reconciled.
|
|
21
|
+
The stable physical-camera identity is the **image name**, preserved as ``frame_XXXX/<name>``
|
|
22
|
+
and surfaced in ``time_meta.devices`` keyed by name.
|
|
23
|
+
|
|
24
|
+
* **Timestamps: frame instant.** All images in one frame share the frame's time, so every
|
|
25
|
+
point's per-track-centroid time is exactly that instant (II.1 degenerates cleanly). Real
|
|
26
|
+
timestamps are used when supplied; otherwise synthesized as ``frame_index * frame_interval_ns``
|
|
27
|
+
and marked synthetic in ``time_meta`` (clock_domain ``synthetic_frame_index``).
|
|
28
|
+
|
|
29
|
+
Reads base models via pycolmap (optional dependency); writes COLMAP text + text sidecars.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from collections import defaultdict
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from colmap4d import model, sidecar
|
|
39
|
+
|
|
40
|
+
# 30 fps default when no real timestamps are provided.
|
|
41
|
+
DEFAULT_FRAME_INTERVAL_NS = 33_333_333
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ConversionResult:
|
|
46
|
+
out_dir: Path
|
|
47
|
+
num_frames: int
|
|
48
|
+
num_cameras: int
|
|
49
|
+
num_images: int
|
|
50
|
+
num_points: int
|
|
51
|
+
frame_interval_ns: int
|
|
52
|
+
synthetic_times: bool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fmt(x: float) -> str:
|
|
56
|
+
return f"{x:.12g}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write_cameras_txt(path: Path, cams: list[tuple]) -> None:
|
|
60
|
+
lines = ["# CAMERA_ID, MODEL, WIDTH, HEIGHT, PARAMS[]\n", f"# Number of cameras: {len(cams)}\n"]
|
|
61
|
+
for cid, model_name, w, h, params in cams:
|
|
62
|
+
lines.append(f"{cid} {model_name} {w} {h} " + " ".join(_fmt(p) for p in params) + "\n")
|
|
63
|
+
path.write_text("".join(lines), encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _write_images_txt(path: Path, images: dict, obs: dict) -> None:
|
|
67
|
+
lines = [
|
|
68
|
+
"# IMAGE_ID, QW, QX, QY, QZ, TX, TY, TZ, CAMERA_ID, NAME\n",
|
|
69
|
+
"# POINTS2D[] as (X, Y, POINT3D_ID)\n",
|
|
70
|
+
f"# Number of images: {len(images)}\n",
|
|
71
|
+
]
|
|
72
|
+
for iid in sorted(images):
|
|
73
|
+
im = images[iid]
|
|
74
|
+
lines.append(
|
|
75
|
+
f"{iid} {_fmt(im['qw'])} {_fmt(im['qx'])} {_fmt(im['qy'])} {_fmt(im['qz'])} "
|
|
76
|
+
f"{_fmt(im['tx'])} {_fmt(im['ty'])} {_fmt(im['tz'])} {im['cam']} {im['name']}\n"
|
|
77
|
+
)
|
|
78
|
+
pts = obs.get(iid, [])
|
|
79
|
+
lines.append(" ".join(f"{_fmt(x)} {_fmt(y)} {pid}" for x, y, pid in pts) + "\n")
|
|
80
|
+
path.write_text("".join(lines), encoding="utf-8")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _write_points3d_txt(path: Path, points: dict) -> None:
|
|
84
|
+
lines = [
|
|
85
|
+
"# POINT3D_ID, X, Y, Z, R, G, B, ERROR, TRACK[] as (IMAGE_ID, POINT2D_IDX)\n",
|
|
86
|
+
f"# Number of points: {len(points)}\n",
|
|
87
|
+
]
|
|
88
|
+
for pid in sorted(points):
|
|
89
|
+
p = points[pid]
|
|
90
|
+
x, y, z = p["xyz"]
|
|
91
|
+
r, g, b = p["rgb"]
|
|
92
|
+
track = " ".join(f"{iid} {idx}" for iid, idx in p["track"])
|
|
93
|
+
head = f"{pid} {_fmt(x)} {_fmt(y)} {_fmt(z)} {r} {g} {b} {_fmt(p['error'])}"
|
|
94
|
+
lines.append(f"{head} {track}\n")
|
|
95
|
+
path.write_text("".join(lines), encoding="utf-8")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def convert_per_frame_colmap(
|
|
99
|
+
frame_dirs: list[str | Path],
|
|
100
|
+
out_dir: str | Path,
|
|
101
|
+
*,
|
|
102
|
+
frame_times_ns: list[int] | None = None,
|
|
103
|
+
frame_interval_ns: int = DEFAULT_FRAME_INTERVAL_NS,
|
|
104
|
+
dedup_points: bool = False,
|
|
105
|
+
) -> ConversionResult:
|
|
106
|
+
"""Merge ``frame_dirs`` (ordered per-frame COLMAP models) into a colmap4d model at ``out_dir``.
|
|
107
|
+
|
|
108
|
+
``frame_times_ns`` gives an explicit timestamp per frame; when omitted, timestamps are
|
|
109
|
+
synthesized as ``index * frame_interval_ns`` and flagged synthetic in ``time_meta``.
|
|
110
|
+
"""
|
|
111
|
+
if dedup_points:
|
|
112
|
+
raise NotImplementedError(
|
|
113
|
+
"cross-frame point dedup is out of scope for v1 (OPEN-6, settled per white paper "
|
|
114
|
+
"Q3/§3.3): per-frame imports keep independent xyzt samples; dedup is a derived "
|
|
115
|
+
"view / downstream optimization. dedup_points is reserved and refused."
|
|
116
|
+
)
|
|
117
|
+
frame_dirs = [Path(d) for d in frame_dirs]
|
|
118
|
+
if frame_times_ns is not None and len(frame_times_ns) != len(frame_dirs):
|
|
119
|
+
raise ValueError("frame_times_ns must match frame_dirs length")
|
|
120
|
+
|
|
121
|
+
cam_map: dict[tuple[int, int], int] = {}
|
|
122
|
+
img_map: dict[tuple[int, int], int] = {}
|
|
123
|
+
new_cameras: list[tuple] = []
|
|
124
|
+
new_images: dict[int, dict] = {}
|
|
125
|
+
new_points: dict[int, dict] = {}
|
|
126
|
+
obs: dict[int, list] = defaultdict(list)
|
|
127
|
+
times: dict[int, int] = {}
|
|
128
|
+
points_t: dict[int, int] = {}
|
|
129
|
+
name_to_camids: dict[str, set[int]] = defaultdict(set)
|
|
130
|
+
camid_to_names: dict[int, set[str]] = defaultdict(set)
|
|
131
|
+
|
|
132
|
+
next_cam = next_img = next_pt = 1
|
|
133
|
+
for f, d in enumerate(frame_dirs):
|
|
134
|
+
rec = model.read_reconstruction(d)
|
|
135
|
+
t = frame_times_ns[f] if frame_times_ns is not None else f * frame_interval_ns
|
|
136
|
+
|
|
137
|
+
for cid, cam in rec.cameras.items():
|
|
138
|
+
cam_map[(f, int(cid))] = next_cam
|
|
139
|
+
new_cameras.append(
|
|
140
|
+
(next_cam, cam.model.name, cam.width, cam.height, [float(p) for p in cam.params])
|
|
141
|
+
)
|
|
142
|
+
next_cam += 1
|
|
143
|
+
|
|
144
|
+
for iid, im in rec.images.items():
|
|
145
|
+
new_img = next_img
|
|
146
|
+
next_img += 1
|
|
147
|
+
img_map[(f, int(iid))] = new_img
|
|
148
|
+
cfw = im.cam_from_world()
|
|
149
|
+
q = cfw.rotation.quat # [x, y, z, w]
|
|
150
|
+
tr = cfw.translation
|
|
151
|
+
new_cam = cam_map[(f, int(im.camera_id))]
|
|
152
|
+
stem = Path(im.name).stem
|
|
153
|
+
new_images[new_img] = {
|
|
154
|
+
"qw": float(q[3]),
|
|
155
|
+
"qx": float(q[0]),
|
|
156
|
+
"qy": float(q[1]),
|
|
157
|
+
"qz": float(q[2]),
|
|
158
|
+
"tx": float(tr[0]),
|
|
159
|
+
"ty": float(tr[1]),
|
|
160
|
+
"tz": float(tr[2]),
|
|
161
|
+
"cam": new_cam,
|
|
162
|
+
"name": f"frame_{f:04d}/{im.name}",
|
|
163
|
+
}
|
|
164
|
+
times[new_img] = t
|
|
165
|
+
name_to_camids[stem].add(new_cam)
|
|
166
|
+
camid_to_names[new_cam].add(stem)
|
|
167
|
+
|
|
168
|
+
for pt in rec.points3D.values():
|
|
169
|
+
new_pt = next_pt
|
|
170
|
+
next_pt += 1
|
|
171
|
+
track = []
|
|
172
|
+
for el in pt.track.elements:
|
|
173
|
+
new_img = img_map[(f, int(el.image_id))]
|
|
174
|
+
xy = rec.images[el.image_id].points2D[el.point2D_idx].xy
|
|
175
|
+
idx = len(obs[new_img])
|
|
176
|
+
obs[new_img].append((float(xy[0]), float(xy[1]), new_pt))
|
|
177
|
+
track.append((new_img, idx))
|
|
178
|
+
new_points[new_pt] = {
|
|
179
|
+
"xyz": [float(v) for v in pt.xyz],
|
|
180
|
+
"rgb": [int(v) for v in pt.color],
|
|
181
|
+
"error": float(pt.error),
|
|
182
|
+
"track": track,
|
|
183
|
+
}
|
|
184
|
+
points_t[new_pt] = t
|
|
185
|
+
|
|
186
|
+
# Device provenance: one device per physical camera name; attach camera_ids only where a
|
|
187
|
+
# global CAMERA_ID maps unambiguously to a single name (keeps validate's uniqueness rule).
|
|
188
|
+
devices = {}
|
|
189
|
+
for stem in sorted(name_to_camids):
|
|
190
|
+
exclusive = sorted(c for c in name_to_camids[stem] if len(camid_to_names[c]) == 1)
|
|
191
|
+
dev = {"note": "physical camera identified by image name across frames"}
|
|
192
|
+
if exclusive:
|
|
193
|
+
dev["camera_ids"] = exclusive
|
|
194
|
+
devices[stem] = dev
|
|
195
|
+
|
|
196
|
+
synthetic = frame_times_ns is None
|
|
197
|
+
ts_note = "synthetic (frame_index * frame_interval_ns)" if synthetic else "provided"
|
|
198
|
+
pt_policy = "frame_instant; per-frame independent points, no cross-frame dedup"
|
|
199
|
+
# Synthetic timestamps are uniform frame_index * interval — the degenerate case of a
|
|
200
|
+
# software-clocked capture where the real per-image offsets were not recorded.
|
|
201
|
+
time_meta = {
|
|
202
|
+
"colmap4d_spec": "1.0",
|
|
203
|
+
"time_convention": "mid_exposure",
|
|
204
|
+
"time_unit": "ns",
|
|
205
|
+
"clock_domain": "synthetic_uniform" if synthetic else "provided",
|
|
206
|
+
"devices": devices,
|
|
207
|
+
"conversion": {
|
|
208
|
+
"source": "per_frame_colmap",
|
|
209
|
+
"num_frames": len(frame_dirs),
|
|
210
|
+
"frame_interval_ns": frame_interval_ns,
|
|
211
|
+
"timestamps": ts_note,
|
|
212
|
+
"point_time_policy": pt_policy,
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
out = Path(out_dir)
|
|
217
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
_write_cameras_txt(out / "cameras.txt", new_cameras)
|
|
219
|
+
_write_images_txt(out / "images.txt", new_images, obs)
|
|
220
|
+
_write_points3d_txt(out / "points3D.txt", new_points)
|
|
221
|
+
sidecar.write_times_txt(out / sidecar.TIMES_TXT, times)
|
|
222
|
+
sidecar.write_points_t_txt(out / sidecar.POINTS_T_TXT, points_t)
|
|
223
|
+
sidecar.write_time_meta(out / sidecar.TIME_META_JSON, time_meta)
|
|
224
|
+
|
|
225
|
+
return ConversionResult(
|
|
226
|
+
out_dir=out,
|
|
227
|
+
num_frames=len(frame_dirs),
|
|
228
|
+
num_cameras=len(new_cameras),
|
|
229
|
+
num_images=len(new_images),
|
|
230
|
+
num_points=len(new_points),
|
|
231
|
+
frame_interval_ns=frame_interval_ns,
|
|
232
|
+
synthetic_times=synthetic,
|
|
233
|
+
)
|
colmap4d/groups.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Derived pseudo-frame grouping. (PLACEHOLDER)
|
|
2
|
+
|
|
3
|
+
Intent (not implemented in WP0): ``group_by_time(times, eps_ns)`` clusters images
|
|
4
|
+
whose timestamps fall within eps_ns into pseudo-frames, and optional read/write of a
|
|
5
|
+
`groups.txt` derived view. Per spec Part III, grouping is a DERIVED view: the source
|
|
6
|
+
of truth is always ``times`` + a chosen epsilon, recomputable on demand. groups.txt is
|
|
7
|
+
never required and never authoritative.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def group_by_time(times: dict[int, int], eps_ns: int) -> list[list[int]]: # noqa: ARG001
|
|
14
|
+
raise NotImplementedError("colmap4d.groups is a WP0 placeholder.")
|