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 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.")