colmap4d 1.0.2__tar.gz → 1.0.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: colmap4d
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: Standard COLMAP sparse model + per-image and per-point timestamps (x, y, z, t).
5
5
  Author: colmap4d authors
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "colmap4d"
7
- version = "1.0.2"
7
+ version = "1.0.3"
8
8
  description = "Standard COLMAP sparse model + per-image and per-point timestamps (x, y, z, t)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -32,7 +32,7 @@ from colmap4d.sidecar import (
32
32
  write_times_txt,
33
33
  )
34
34
 
35
- __version__ = "1.0.2"
35
+ __version__ = "1.0.3"
36
36
 
37
37
  __all__ = [
38
38
  "TIMELESS",
@@ -12,11 +12,20 @@ Binary layout follows COLMAP's ``reconstruction_io`` (little-endian, count-prefi
12
12
  name\\0, u64 n2d, [f64 x, f64 y, u64 point3D_id] * n2d]
13
13
  points3D.bin : u64 count, then [u64 id, f64 x y z, u8 r g b, f64 error,
14
14
  u64 track_len, [u32 image_id, u32 point2D_idx] * track_len]
15
+
16
+ The binary readers decode each file from one in-memory buffer, slicing the fixed-size runs
17
+ (2D observations, tracks) in bulk instead of one ``struct`` call per element. Callers that only
18
+ need poses / point positions can skip the per-observation data with ``include_observations`` /
19
+ ``include_tracks``; the skipped fields are then ``None`` rather than lists.
15
20
  """
16
21
 
17
22
  from __future__ import annotations
18
23
 
24
+ import gc
19
25
  import struct
26
+ import sys
27
+ from collections.abc import Iterator
28
+ from contextlib import contextmanager
20
29
  from dataclasses import dataclass, field
21
30
  from pathlib import Path
22
31
 
@@ -57,8 +66,9 @@ class Image:
57
66
  camera_id: int
58
67
  qvec: list[float] # [qw, qx, qy, qz]
59
68
  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
69
+ # Both are None when read with include_observations=False.
70
+ xys: list[tuple[float, float]] | None = field(default_factory=list)
71
+ point3D_ids: list[int] | None = field(default_factory=list) # INVALID_POINT3D_ID where none
62
72
 
63
73
 
64
74
  @dataclass
@@ -67,7 +77,7 @@ class Point3D:
67
77
  xyz: list[float]
68
78
  rgb: list[int]
69
79
  error: float
70
- track: list[tuple[int, int]] # (image_id, point2D_idx)
80
+ track: list[tuple[int, int]] | None # (image_id, point2D_idx); None if not read
71
81
 
72
82
 
73
83
  @dataclass
@@ -95,6 +105,53 @@ def _w(f, fmt: str, *vals) -> None:
95
105
  f.write(struct.pack("<" + fmt, *vals))
96
106
 
97
107
 
108
+ _U64 = struct.Struct("<Q")
109
+ _IMAGE_HEAD = struct.Struct("<IdddddddI")
110
+ _OBSERVATION = struct.Struct("<ddQ")
111
+ _POINT_HEAD = struct.Struct("<QdddBBBdQ")
112
+ _TRACK_ELEM = struct.Struct("<II")
113
+ # memoryview.cast decodes in native byte order, which matches the file only on little-endian.
114
+ _NATIVE_LITTLE_ENDIAN = sys.byteorder == "little"
115
+
116
+
117
+ @contextmanager
118
+ def _gc_paused() -> Iterator[None]:
119
+ """Pause the cyclic GC while building millions of acyclic records; repeated collections
120
+ over the growing heap otherwise cost more than the parsing itself."""
121
+ was_enabled = gc.isenabled()
122
+ gc.disable()
123
+ try:
124
+ yield
125
+ finally:
126
+ if was_enabled:
127
+ gc.enable()
128
+
129
+
130
+ def _span(data: bytes, off: int, size: int) -> int:
131
+ """End offset of ``size`` bytes at ``off``; raise like a short ``struct`` read if past EOF."""
132
+ end = off + size
133
+ if end > len(data):
134
+ raise struct.error(f"unpack requires a buffer of {size} bytes")
135
+ return end
136
+
137
+
138
+ def _observations(mv: memoryview, off: int, n2d: int) -> tuple[list, list]:
139
+ """``(xys, point3D_ids)`` from ``n2d`` packed ``[f64 x, f64 y, u64 id]`` records."""
140
+ block = mv[off : off + n2d * _OBSERVATION.size]
141
+ if _NATIVE_LITTLE_ENDIAN:
142
+ coords = block.cast("d")
143
+ xys = list(zip(coords[0::3].tolist(), coords[1::3].tolist()))
144
+ return xys, block.cast("Q")[2::3].tolist()
145
+ rows = list(_OBSERVATION.iter_unpack(block))
146
+ return [(x, y) for x, y, _ in rows], [pid for _, _, pid in rows]
147
+
148
+
149
+ def _require_loaded(value, what: str, record_id: int):
150
+ if value is None:
151
+ raise ValueError(f"{what} {record_id} was read without that data; cannot write it")
152
+ return value
153
+
154
+
98
155
  # --------------------------------------------------------------------------- #
99
156
  # text parsing helpers
100
157
  # --------------------------------------------------------------------------- #
@@ -144,7 +201,7 @@ def write_cameras_bin(path: str | Path, cams: dict[int, Camera]) -> None:
144
201
  # --------------------------------------------------------------------------- #
145
202
  # images
146
203
  # --------------------------------------------------------------------------- #
147
- def read_images_txt(path: str | Path) -> dict[int, Image]:
204
+ def read_images_txt(path: str | Path, *, include_observations: bool = True) -> dict[int, Image]:
148
205
  lines = _data_lines(path)
149
206
  images = {}
150
207
  for i in range(0, len(lines), 2):
@@ -154,6 +211,9 @@ def read_images_txt(path: str | Path) -> dict[int, Image]:
154
211
  tvec = [float(x) for x in head[5:8]]
155
212
  cam_id = int(head[8])
156
213
  name = head[9]
214
+ if not include_observations:
215
+ images[iid] = Image(iid, name, cam_id, qvec, tvec, None, None)
216
+ continue
157
217
  xys, ids = [], []
158
218
  pts = lines[i + 1].split() if i + 1 < len(lines) else []
159
219
  for j in range(0, len(pts), 3):
@@ -164,24 +224,29 @@ def read_images_txt(path: str | Path) -> dict[int, Image]:
164
224
  return images
165
225
 
166
226
 
167
- def read_images_bin(path: str | Path) -> dict[int, Image]:
227
+ def read_images_bin(path: str | Path, *, include_observations: bool = True) -> dict[int, Image]:
228
+ """Read images.bin. With ``include_observations=False`` the 2D observations are skipped
229
+ and ``xys`` / ``point3D_ids`` are ``None``."""
230
+ data = Path(path).read_bytes()
231
+ mv = memoryview(data)
168
232
  images = {}
169
- with open(path, "rb") as f:
170
- (n,) = _r(f, "Q")
233
+ (n,) = _U64.unpack_from(data, 0)
234
+ off = _U64.size
235
+ with _gc_paused():
171
236
  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
- )
237
+ iid, qw, qx, qy, qz, tx, ty, tz, cam_id = _IMAGE_HEAD.unpack_from(data, off)
238
+ off += _IMAGE_HEAD.size
239
+ name_end = data.find(b"\x00", off)
240
+ if name_end < 0:
241
+ name_end = len(data)
242
+ name = data[off:name_end].decode("utf-8")
243
+ off = name_end + 1
244
+ (n2d,) = _U64.unpack_from(data, off)
245
+ off += _U64.size
246
+ end = _span(data, off, n2d * _OBSERVATION.size)
247
+ xys, ids = _observations(mv, off, n2d) if include_observations else (None, None)
248
+ off = end
249
+ images[iid] = Image(iid, name, cam_id, [qw, qx, qy, qz], [tx, ty, tz], xys, ids)
185
250
  return images
186
251
 
187
252
 
@@ -193,15 +258,16 @@ def write_images_bin(path: str | Path, images: dict[int, Image]) -> None:
193
258
  _w(f, "Iddddddd", iid, *im.qvec, *im.tvec)
194
259
  _w(f, "I", im.camera_id)
195
260
  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):
261
+ xys = _require_loaded(im.xys, "image", iid)
262
+ _w(f, "Q", len(xys))
263
+ for (x, y), pid in zip(xys, im.point3D_ids):
198
264
  _w(f, "ddQ", x, y, pid)
199
265
 
200
266
 
201
267
  # --------------------------------------------------------------------------- #
202
268
  # points3D
203
269
  # --------------------------------------------------------------------------- #
204
- def read_points3D_txt(path: str | Path) -> dict[int, Point3D]:
270
+ def read_points3D_txt(path: str | Path, *, include_tracks: bool = True) -> dict[int, Point3D]:
205
271
  pts = {}
206
272
  for line in _data_lines(path):
207
273
  p = line.split()
@@ -210,18 +276,36 @@ def read_points3D_txt(path: str | Path) -> dict[int, Point3D]:
210
276
  rgb = [int(x) for x in p[4:7]]
211
277
  error = float(p[7])
212
278
  rest = p[8:]
213
- track = [(int(rest[k]), int(rest[k + 1])) for k in range(0, len(rest), 2)]
279
+ track = (
280
+ [(int(rest[k]), int(rest[k + 1])) for k in range(0, len(rest), 2)]
281
+ if include_tracks
282
+ else None
283
+ )
214
284
  pts[pid] = Point3D(pid, xyz, rgb, error, track)
215
285
  return pts
216
286
 
217
287
 
218
- def read_points3D_bin(path: str | Path) -> dict[int, Point3D]:
288
+ def read_points3D_bin(path: str | Path, *, include_tracks: bool = True) -> dict[int, Point3D]:
289
+ """Read points3D.bin. With ``include_tracks=False`` the tracks are skipped and
290
+ ``track`` is ``None``."""
291
+ data = Path(path).read_bytes()
292
+ mv = memoryview(data)
293
+ head = _POINT_HEAD.unpack_from
294
+ head_size = _POINT_HEAD.size
295
+ track_rows = _TRACK_ELEM.iter_unpack
296
+ size = len(data)
219
297
  pts = {}
220
- with open(path, "rb") as f:
221
- (n,) = _r(f, "Q")
298
+ (n,) = _U64.unpack_from(data, 0)
299
+ off = _U64.size
300
+ with _gc_paused():
222
301
  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)]
302
+ pid, x, y, z, r, g, b, error, tl = head(data, off)
303
+ off += head_size
304
+ end = off + tl * 8 # u32 image_id + u32 point2D_idx; inline: hot loop
305
+ if end > size:
306
+ _span(data, off, tl * 8)
307
+ track = list(track_rows(mv[off:end])) if include_tracks else None
308
+ off = end
225
309
  pts[pid] = Point3D(pid, [x, y, z], [r, g, b], error, track)
226
310
  return pts
227
311
 
@@ -231,17 +315,24 @@ def write_points3D_bin(path: str | Path, pts: dict[int, Point3D]) -> None:
231
315
  _w(f, "Q", len(pts))
232
316
  for pid in sorted(pts):
233
317
  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:
318
+ track = _require_loaded(p.track, "point3D", pid)
319
+ _w(f, "QdddBBBdQ", pid, *p.xyz, *p.rgb, p.error, len(track))
320
+ for img_id, idx in track:
236
321
  _w(f, "II", img_id, idx)
237
322
 
238
323
 
239
324
  # --------------------------------------------------------------------------- #
240
325
  # whole model
241
326
  # --------------------------------------------------------------------------- #
242
- def read_model(model_dir: str | Path) -> BaseModel:
327
+ def read_model(
328
+ model_dir: str | Path, *, include_observations: bool = True, include_tracks: bool = True
329
+ ) -> BaseModel:
243
330
  """Read cameras/images/points3D from a COLMAP model dir. Binary is preferred over text
244
- when both are present (matching COLMAP)."""
331
+ when both are present (matching COLMAP).
332
+
333
+ ``include_observations=False`` skips the per-image 2D observations and
334
+ ``include_tracks=False`` the per-point tracks (those fields are then ``None``); use them
335
+ when only poses, point positions and ids are needed — they dominate time and memory."""
245
336
  d = Path(model_dir)
246
337
 
247
338
  def pick(stem: str):
@@ -254,8 +345,10 @@ def read_model(model_dir: str | Path) -> BaseModel:
254
345
  cpath, ck = pick("cameras")
255
346
  ipath, ik = pick("images")
256
347
  ppath, pk = pick("points3D")
348
+ read_images = read_images_bin if ik == "bin" else read_images_txt
349
+ read_points = read_points3D_bin if pk == "bin" else read_points3D_txt
257
350
  return BaseModel(
258
351
  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),
352
+ images=read_images(ipath, include_observations=include_observations),
353
+ points3D=read_points(ppath, include_tracks=include_tracks),
261
354
  )
@@ -25,9 +25,17 @@ from colmap4d.colmap_io import BaseModel
25
25
  from colmap4d.sidecar import TIMELESS, Sidecars
26
26
 
27
27
 
28
- def read_base_model(model_dir: str | Path) -> BaseModel:
29
- """Read the COLMAP base model with the zero-dependency pure-Python reader."""
30
- return colmap_io.read_model(model_dir)
28
+ def read_base_model(
29
+ model_dir: str | Path, *, include_observations: bool = True, include_tracks: bool = True
30
+ ) -> BaseModel:
31
+ """Read the COLMAP base model with the zero-dependency pure-Python reader.
32
+
33
+ Pass ``include_observations=False`` / ``include_tracks=False`` to skip the 2D observations /
34
+ point tracks when only poses, point positions and ids are needed (see
35
+ :func:`colmap4d.colmap_io.read_model`)."""
36
+ return colmap_io.read_model(
37
+ model_dir, include_observations=include_observations, include_tracks=include_tracks
38
+ )
31
39
 
32
40
 
33
41
  def read_reconstruction(model_dir: str | Path) -> Any:
@@ -97,15 +105,29 @@ class ModelView:
97
105
  return min(vals) if vals else None
98
106
 
99
107
 
100
- def load_model_view(model_dir: str | Path, strict: bool = False) -> ModelView:
101
- """Read the COLMAP model (zero-dep) + sidecars and return a joined :class:`ModelView`."""
102
- base = read_base_model(model_dir)
108
+ def load_model_view(
109
+ model_dir: str | Path,
110
+ strict: bool = False,
111
+ *,
112
+ include_observations: bool = True,
113
+ include_tracks: bool = True,
114
+ ) -> ModelView:
115
+ """Read the COLMAP model (zero-dep) + sidecars and return a joined :class:`ModelView`.
116
+
117
+ ``include_observations`` / ``include_tracks`` are forwarded to :func:`read_base_model`."""
118
+ base = read_base_model(
119
+ model_dir, include_observations=include_observations, include_tracks=include_tracks
120
+ )
103
121
  sc = sidecar.load_sidecars(model_dir, strict=strict)
104
122
  return ModelView(base, sc)
105
123
 
106
124
 
107
125
  def validate_full(model_dir: str | Path) -> list[validate.Problem]:
108
126
  """Run the complete validate suite, wiring the model's id sets (read zero-dep) so the
109
- dangling-id checks (Part I.D) actually fire. Compute status with ``validate.exit_code``."""
110
- base = read_base_model(model_dir)
127
+ dangling-id checks (Part I.D) actually fire. Compute status with ``validate.exit_code``.
128
+
129
+ Only the id sets are needed, so observations and tracks are not read. A caller that
130
+ already holds a :class:`ModelView` can call ``validate.validate`` with its id sets instead
131
+ of re-reading the model."""
132
+ base = read_base_model(model_dir, include_observations=False, include_tracks=False)
111
133
  return validate.validate(model_dir, image_ids=base.image_ids(), point_ids=base.point_ids())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: colmap4d
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: Standard COLMAP sparse model + per-image and per-point timestamps (x, y, z, t).
5
5
  Author: colmap4d authors
6
6
  License: Apache-2.0
File without changes
File without changes
File without changes