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/model.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Full colmap4d model = COLMAP base model + time sidecars.
|
|
2
|
+
|
|
3
|
+
This is the reference reader's core entry point: ``import colmap4d`` then
|
|
4
|
+
``colmap4d.load_model_view(dir)`` gives you a model whose images and points carry time.
|
|
5
|
+
|
|
6
|
+
The COLMAP base model (cameras/images/points3D) is read by :mod:`colmap4d.colmap_io`, a
|
|
7
|
+
zero-dependency pure-Python reader — so the default path needs no compiled dependency.
|
|
8
|
+
Where **pycolmap** is installed it is preferred (authoritative, and it understands the 3.12
|
|
9
|
+
rigs/frames binary variants); :func:`read_reconstruction` exposes it for callers that need
|
|
10
|
+
full geometry (e.g. the per-frame converter).
|
|
11
|
+
|
|
12
|
+
Model-aware behavior lives here: the timeless (Part I.A) and dangling-ignored (Part I.D)
|
|
13
|
+
rules are applied against the actual set of model ids, and :mod:`colmap4d.validate`'s graded
|
|
14
|
+
checks are wired to that id set.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from colmap4d import colmap_io, sidecar, validate
|
|
24
|
+
from colmap4d.colmap_io import BaseModel
|
|
25
|
+
from colmap4d.sidecar import TIMELESS, Sidecars
|
|
26
|
+
|
|
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)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_reconstruction(model_dir: str | Path) -> Any:
|
|
34
|
+
"""Read the base model via pycolmap (optional dependency), returning its
|
|
35
|
+
``pycolmap.Reconstruction``. Use when you need full geometry (poses, 2D observations,
|
|
36
|
+
rigs/frames). Raises a clear error if pycolmap is not installed."""
|
|
37
|
+
try:
|
|
38
|
+
import pycolmap
|
|
39
|
+
except ImportError as e: # pragma: no cover - exercised only without the extra
|
|
40
|
+
raise ImportError(
|
|
41
|
+
"read_reconstruction needs pycolmap. Install it with: pip install 'colmap4d[model]'"
|
|
42
|
+
" (or: pip install pycolmap). For a dependency-free read use read_base_model()."
|
|
43
|
+
) from e
|
|
44
|
+
return pycolmap.Reconstruction(str(model_dir))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ModelView:
|
|
49
|
+
"""A COLMAP base model joined with its colmap4d time sidecars.
|
|
50
|
+
|
|
51
|
+
The join applies the two Part I rules that need the base model:
|
|
52
|
+
* temporally-unbounded (I.A): a model point absent from ``points_t`` has time ``None``.
|
|
53
|
+
* dangling ids ignored (I.D): sidecar ids absent from the model are dropped from the
|
|
54
|
+
``effective_*`` views (they remain in ``sidecars`` raw, and are a ``validate`` warning).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
base: BaseModel
|
|
58
|
+
sidecars: Sidecars
|
|
59
|
+
|
|
60
|
+
def image_ids(self) -> set[int]:
|
|
61
|
+
return self.base.image_ids()
|
|
62
|
+
|
|
63
|
+
def point_ids(self) -> set[int]:
|
|
64
|
+
return self.base.point_ids()
|
|
65
|
+
|
|
66
|
+
def effective_times(self) -> dict[int, int]:
|
|
67
|
+
"""image_id -> t_ns, restricted to images present in the model."""
|
|
68
|
+
ids = self.image_ids()
|
|
69
|
+
return {i: t for i, t in self.sidecars.times.items() if i in ids}
|
|
70
|
+
|
|
71
|
+
def effective_points_t(self) -> dict[int, int]:
|
|
72
|
+
"""point3d_id -> t_ns, restricted to points present in the model (dangling dropped)."""
|
|
73
|
+
ids = self.point_ids()
|
|
74
|
+
return {p: t for p, t in self.sidecars.points_t.items() if p in ids}
|
|
75
|
+
|
|
76
|
+
def image_time(self, image_id: int) -> int | None:
|
|
77
|
+
"""t_ns for a model image, or ``None`` if it carries no timestamp."""
|
|
78
|
+
if image_id not in self.image_ids():
|
|
79
|
+
raise KeyError(f"image {image_id} not in model")
|
|
80
|
+
return self.sidecars.times.get(image_id, TIMELESS)
|
|
81
|
+
|
|
82
|
+
def point_time(self, point3d_id: int) -> int | None:
|
|
83
|
+
"""t_ns for a model point, or ``TIMELESS`` (None) if temporally-unbounded."""
|
|
84
|
+
if point3d_id not in self.point_ids():
|
|
85
|
+
raise KeyError(f"point3D {point3d_id} not in model")
|
|
86
|
+
return self.sidecars.points_t.get(point3d_id, TIMELESS)
|
|
87
|
+
|
|
88
|
+
def t0_ns(self) -> int | None:
|
|
89
|
+
"""Rebase origin = min timestamp over the model's **effective** records (spec I.B).
|
|
90
|
+
|
|
91
|
+
Computed over the dangling-dropped join (``effective_times`` ∪ ``effective_points_t``),
|
|
92
|
+
so a dangling id with an early timestamp cannot shift t0 — this is the authoritative t0
|
|
93
|
+
two implementations must agree on. ``None`` if the model carries no timestamps.
|
|
94
|
+
(``Sidecars.t0_ns`` is the model-less approximation over raw records.)
|
|
95
|
+
"""
|
|
96
|
+
vals = list(self.effective_times().values()) + list(self.effective_points_t().values())
|
|
97
|
+
return min(vals) if vals else None
|
|
98
|
+
|
|
99
|
+
|
|
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)
|
|
103
|
+
sc = sidecar.load_sidecars(model_dir, strict=strict)
|
|
104
|
+
return ModelView(base, sc)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_full(model_dir: str | Path) -> list[validate.Problem]:
|
|
108
|
+
"""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)
|
|
111
|
+
return validate.validate(model_dir, image_ids=base.image_ids(), point_ids=base.point_ids())
|
colmap4d/sidecar.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Zero-dependency reader/writer for the colmap4d time sidecars.
|
|
2
|
+
|
|
3
|
+
This module handles ONLY the colmap4d delta over a standard COLMAP sparse model:
|
|
4
|
+
|
|
5
|
+
times -- per-image timestamps (times.txt / times.bin)
|
|
6
|
+
points_t -- per-point timestamps (points_t.txt / points_t.bin)
|
|
7
|
+
time_meta -- time-axis semantics/prov. (time_meta.json)
|
|
8
|
+
|
|
9
|
+
It deliberately does NOT parse the COLMAP base model (cameras/images/points3D).
|
|
10
|
+
That belongs in ``model.py`` and is delegated to pycolmap (an optional dependency),
|
|
11
|
+
keeping "I just want to read the timestamps" a standard-library-only path.
|
|
12
|
+
|
|
13
|
+
Timestamps are int64 nanoseconds (see spec Part I.B). ``points_t`` is a PARTIAL map:
|
|
14
|
+
a point absent from it is *temporally-unbounded* and is represented here by simple
|
|
15
|
+
absence from the returned dict (see :func:`is_temporally_unbounded`).
|
|
16
|
+
|
|
17
|
+
Binary layout is defined normatively by the spec, section **I.E "Binary sidecar layout"**
|
|
18
|
+
(little-endian, uint64 count prefix; times record = uint32 image_id + int64 t_ns; points_t
|
|
19
|
+
record = uint64 point3d_id + int64 t_ns; id widths match COLMAP's image_t/point3D_t). This
|
|
20
|
+
module implements that section — the spec, not this docstring, is the source of truth.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import struct
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
# Canonical sidecar file names, relative to a COLMAP sparse model directory.
|
|
31
|
+
TIMES_TXT = "times.txt"
|
|
32
|
+
TIMES_BIN = "times.bin"
|
|
33
|
+
POINTS_T_TXT = "points_t.txt"
|
|
34
|
+
POINTS_T_BIN = "points_t.bin"
|
|
35
|
+
TIME_META_JSON = "time_meta.json"
|
|
36
|
+
|
|
37
|
+
# Struct formats (little-endian). Count prefix is a uint64.
|
|
38
|
+
_COUNT = struct.Struct("<Q")
|
|
39
|
+
_TIMES_REC = struct.Struct("<Iq") # uint32 image_id, int64 t_ns
|
|
40
|
+
_POINTS_T_REC = struct.Struct("<Qq") # uint64 point3d_id, int64 t_ns
|
|
41
|
+
|
|
42
|
+
# Sentinel for a point with no timestamp (present at all t).
|
|
43
|
+
TIMELESS = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
# raw pair parsing (duplicates preserved) + normative collapse to a dict
|
|
48
|
+
# --------------------------------------------------------------------------- #
|
|
49
|
+
def _parse_txt_pairs(path: str | Path) -> list[tuple[int, int]]:
|
|
50
|
+
"""All ``(ID, T_NS)`` rows of a text sidecar, in file order, dups preserved."""
|
|
51
|
+
pairs: list[tuple[int, int]] = []
|
|
52
|
+
with open(path, encoding="utf-8") as f:
|
|
53
|
+
for raw in f:
|
|
54
|
+
line = raw.strip()
|
|
55
|
+
if not line or line.startswith("#"):
|
|
56
|
+
continue
|
|
57
|
+
parts = line.split()
|
|
58
|
+
if len(parts) < 2:
|
|
59
|
+
raise ValueError(f"{path}: malformed line: {raw!r}")
|
|
60
|
+
pairs.append((int(parts[0]), int(parts[1])))
|
|
61
|
+
return pairs
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_bin_pairs(path: str | Path, rec: struct.Struct) -> list[tuple[int, int]]:
|
|
65
|
+
"""All ``(ID, T_NS)`` records of a binary sidecar, in file order, dups preserved."""
|
|
66
|
+
data = Path(path).read_bytes()
|
|
67
|
+
(count,) = _COUNT.unpack_from(data, 0)
|
|
68
|
+
off = _COUNT.size
|
|
69
|
+
pairs: list[tuple[int, int]] = []
|
|
70
|
+
for _ in range(count):
|
|
71
|
+
key, t_ns = rec.unpack_from(data, off)
|
|
72
|
+
pairs.append((key, t_ns))
|
|
73
|
+
off += rec.size
|
|
74
|
+
return pairs
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _pairs_to_dict(pairs: list[tuple[int, int]], strict: bool, path: str | Path) -> dict[int, int]:
|
|
78
|
+
"""Collapse raw pairs to id->t_ns. Duplicate ids resolve **last-wins** (spec Part I:
|
|
79
|
+
a writer MUST NOT emit duplicates; a reader SHOULD accept and take the last one).
|
|
80
|
+
``strict=True`` opts into raising on a duplicate instead."""
|
|
81
|
+
out: dict[int, int] = {}
|
|
82
|
+
for key, t_ns in pairs:
|
|
83
|
+
if strict and key in out:
|
|
84
|
+
raise ValueError(f"{path}: duplicate id {key} (strict mode)")
|
|
85
|
+
out[key] = t_ns # last-wins
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _write_id_time_txt(path: str | Path, mapping: dict[int, int], header: str) -> None:
|
|
90
|
+
lines = [header.rstrip("\n") + "\n"]
|
|
91
|
+
for key in sorted(mapping):
|
|
92
|
+
lines.append(f"{key} {mapping[key]}\n")
|
|
93
|
+
Path(path).write_text("".join(lines), encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _write_id_time_bin(path: str | Path, mapping: dict[int, int], rec: struct.Struct) -> None:
|
|
97
|
+
buf = bytearray(_COUNT.pack(len(mapping)))
|
|
98
|
+
for key in sorted(mapping):
|
|
99
|
+
buf += rec.pack(key, mapping[key])
|
|
100
|
+
Path(path).write_bytes(bytes(buf))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------------------- #
|
|
104
|
+
# times
|
|
105
|
+
# --------------------------------------------------------------------------- #
|
|
106
|
+
def read_times_txt(path: str | Path, strict: bool = False) -> dict[int, int]:
|
|
107
|
+
"""image_id -> t_ns, from times.txt (last-wins on dup ids; see :func:`_pairs_to_dict`)."""
|
|
108
|
+
return _pairs_to_dict(_parse_txt_pairs(path), strict, path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_times_bin(path: str | Path, strict: bool = False) -> dict[int, int]:
|
|
112
|
+
"""image_id -> t_ns, from times.bin (last-wins on dup ids)."""
|
|
113
|
+
return _pairs_to_dict(_parse_bin_pairs(path, _TIMES_REC), strict, path)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read_times_pairs(path: str | Path) -> list[tuple[int, int]]:
|
|
117
|
+
"""Raw (image_id, t_ns) rows with duplicates preserved (for validation)."""
|
|
118
|
+
if str(path).endswith(".bin"):
|
|
119
|
+
return _parse_bin_pairs(path, _TIMES_REC)
|
|
120
|
+
return _parse_txt_pairs(path)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def write_times_txt(path: str | Path, times: dict[int, int]) -> None:
|
|
124
|
+
_write_id_time_txt(
|
|
125
|
+
path,
|
|
126
|
+
times,
|
|
127
|
+
f"# colmap4d times: IMAGE_ID, T_NS (int64 ns)\n# Number of images: {len(times)}",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def write_times_bin(path: str | Path, times: dict[int, int]) -> None:
|
|
132
|
+
_write_id_time_bin(path, times, _TIMES_REC)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --------------------------------------------------------------------------- #
|
|
136
|
+
# points_t
|
|
137
|
+
# --------------------------------------------------------------------------- #
|
|
138
|
+
def read_points_t_txt(path: str | Path, strict: bool = False) -> dict[int, int]:
|
|
139
|
+
"""point3d_id -> t_ns, from points_t.txt (PARTIAL map; last-wins on dup ids)."""
|
|
140
|
+
return _pairs_to_dict(_parse_txt_pairs(path), strict, path)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def read_points_t_bin(path: str | Path, strict: bool = False) -> dict[int, int]:
|
|
144
|
+
"""point3d_id -> t_ns, from points_t.bin (PARTIAL map; last-wins on dup ids)."""
|
|
145
|
+
return _pairs_to_dict(_parse_bin_pairs(path, _POINTS_T_REC), strict, path)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def read_points_t_pairs(path: str | Path) -> list[tuple[int, int]]:
|
|
149
|
+
"""Raw (point3d_id, t_ns) rows with duplicates preserved (for validation)."""
|
|
150
|
+
if str(path).endswith(".bin"):
|
|
151
|
+
return _parse_bin_pairs(path, _POINTS_T_REC)
|
|
152
|
+
return _parse_txt_pairs(path)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def write_points_t_txt(path: str | Path, points_t: dict[int, int]) -> None:
|
|
156
|
+
_write_id_time_txt(
|
|
157
|
+
path,
|
|
158
|
+
points_t,
|
|
159
|
+
"# colmap4d points_t: POINT3D_ID, T_NS (int64 ns); PARTIAL map, "
|
|
160
|
+
"absent point == temporally-unbounded\n"
|
|
161
|
+
f"# Number of points with a timestamp: {len(points_t)}",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def write_points_t_bin(path: str | Path, points_t: dict[int, int]) -> None:
|
|
166
|
+
_write_id_time_bin(path, points_t, _POINTS_T_REC)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def is_temporally_unbounded(points_t: dict[int, int], point3d_id: int) -> bool:
|
|
170
|
+
"""True if the point has no timestamp (present at all t). See spec Part I.A."""
|
|
171
|
+
return point3d_id not in points_t
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --------------------------------------------------------------------------- #
|
|
175
|
+
# time_meta
|
|
176
|
+
# --------------------------------------------------------------------------- #
|
|
177
|
+
def read_time_meta(path: str | Path) -> dict:
|
|
178
|
+
with open(path, encoding="utf-8") as f:
|
|
179
|
+
return json.load(f)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def write_time_meta(path: str | Path, meta: dict) -> None:
|
|
183
|
+
Path(path).write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --------------------------------------------------------------------------- #
|
|
187
|
+
# directory-level loading (backward compatible)
|
|
188
|
+
# --------------------------------------------------------------------------- #
|
|
189
|
+
@dataclass
|
|
190
|
+
class Sidecars:
|
|
191
|
+
"""The colmap4d time layer for one sparse model directory.
|
|
192
|
+
|
|
193
|
+
A plain COLMAP model (no sidecars) yields empty maps and ``time_meta is None``;
|
|
194
|
+
this is the backward-compatibility baseline and must never raise.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
times: dict[int, int] = field(default_factory=dict)
|
|
198
|
+
points_t: dict[int, int] = field(default_factory=dict)
|
|
199
|
+
time_meta: dict | None = None
|
|
200
|
+
|
|
201
|
+
def point_time(self, point3d_id: int) -> int | None:
|
|
202
|
+
"""t_ns for a point, or ``TIMELESS`` (None) if temporally-unbounded."""
|
|
203
|
+
return self.points_t.get(point3d_id, TIMELESS)
|
|
204
|
+
|
|
205
|
+
def t0_ns(self) -> int | None:
|
|
206
|
+
"""Rebase origin = min timestamp over the **raw** sidecar records — an APPROXIMATION.
|
|
207
|
+
|
|
208
|
+
The authoritative t0 (spec I.B) is the min over the model-joined, dangling-dropped
|
|
209
|
+
records; this method has no base model, so a dangling id with an early timestamp would
|
|
210
|
+
pull it earlier. Use ``colmap4d.model.ModelView.t0_ns()`` when the base model is
|
|
211
|
+
available. ``None`` when there are no timestamps at all.
|
|
212
|
+
"""
|
|
213
|
+
vals = list(self.times.values()) + list(self.points_t.values())
|
|
214
|
+
return min(vals) if vals else None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def load_sidecars(model_dir: str | Path, strict: bool = False) -> Sidecars:
|
|
218
|
+
"""Load the time sidecars from a COLMAP sparse model directory.
|
|
219
|
+
|
|
220
|
+
Binary is preferred over text when both are present (matching COLMAP). Missing
|
|
221
|
+
sidecars are treated as empty, never as an error. Duplicate ids resolve last-wins
|
|
222
|
+
(spec Part I); pass ``strict=True`` to raise on a duplicate instead.
|
|
223
|
+
|
|
224
|
+
Note: this reader does not know the base model, so it cannot drop ids that are
|
|
225
|
+
absent from the model. Ignoring model-absent (dangling) ids is the consuming
|
|
226
|
+
layer's job (spec Part I: a reader MUST ignore ids not present in the model); see
|
|
227
|
+
``colmap4d.model`` and ``colmap4d.validate``.
|
|
228
|
+
"""
|
|
229
|
+
d = Path(model_dir)
|
|
230
|
+
sc = Sidecars()
|
|
231
|
+
|
|
232
|
+
if (d / TIMES_BIN).exists():
|
|
233
|
+
sc.times = read_times_bin(d / TIMES_BIN, strict=strict)
|
|
234
|
+
elif (d / TIMES_TXT).exists():
|
|
235
|
+
sc.times = read_times_txt(d / TIMES_TXT, strict=strict)
|
|
236
|
+
|
|
237
|
+
if (d / POINTS_T_BIN).exists():
|
|
238
|
+
sc.points_t = read_points_t_bin(d / POINTS_T_BIN, strict=strict)
|
|
239
|
+
elif (d / POINTS_T_TXT).exists():
|
|
240
|
+
sc.points_t = read_points_t_txt(d / POINTS_T_TXT, strict=strict)
|
|
241
|
+
|
|
242
|
+
if (d / TIME_META_JSON).exists():
|
|
243
|
+
sc.time_meta = read_time_meta(d / TIME_META_JSON)
|
|
244
|
+
|
|
245
|
+
return sc
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def rebase_to_seconds_f32(t_ns: int, t0_ns: int) -> float:
|
|
249
|
+
"""Convert an int64-ns timestamp to a rebased float ``(t - t0)`` in seconds.
|
|
250
|
+
|
|
251
|
+
Rendering/GPU code MUST rebase before narrowing to float32: raw ns (~1.7e18)
|
|
252
|
+
overflows float32's 24-bit mantissa, but ``(t - t0)`` over a capture spans only
|
|
253
|
+
seconds and keeps ~microsecond precision as float32. See spec Part I.B.
|
|
254
|
+
"""
|
|
255
|
+
return (t_ns - t0_ns) / 1e9
|
colmap4d/validate.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""`colmap4d validate` — model sanity checks with graded severity and exit codes.
|
|
2
|
+
|
|
3
|
+
Conformance is a claim a CI job must be able to reject, so `validate` grades problems
|
|
4
|
+
instead of only warning (all-warnings ⇒ exit 0 always ⇒ "conforms" becomes unfalsifiable):
|
|
5
|
+
|
|
6
|
+
ERROR -- violates a spec MUST; exit code is non-zero. Duplicate ids and
|
|
7
|
+
device/camera_id conflicts are the writer's bug and land here.
|
|
8
|
+
WARNING -- suspicious but has legitimate uses (e.g. a sidecar kept as a superset
|
|
9
|
+
of a filtered model); exit code stays 0 unless the caller opts into
|
|
10
|
+
`strict=True`, which promotes WARNINGs to failures for strict pipelines.
|
|
11
|
+
|
|
12
|
+
Reader tolerance (last-wins on duplicates, ignore model-absent ids) is defined in the
|
|
13
|
+
spec and implemented in :mod:`colmap4d.sidecar`; this module reports on those situations
|
|
14
|
+
rather than silently absorbing them.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from colmap4d import sidecar
|
|
23
|
+
|
|
24
|
+
ERROR = "ERROR"
|
|
25
|
+
WARNING = "WARNING"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Problem:
|
|
30
|
+
severity: str # ERROR | WARNING
|
|
31
|
+
code: str
|
|
32
|
+
message: str
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
return f"[{self.severity}] {self.code}: {self.message}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def exit_code(problems: list[Problem], strict: bool = False) -> int:
|
|
39
|
+
"""0 if clean; non-zero if any ERROR, or (when ``strict``) any WARNING."""
|
|
40
|
+
if any(p.severity == ERROR for p in problems):
|
|
41
|
+
return 1
|
|
42
|
+
if strict and any(p.severity == WARNING for p in problems):
|
|
43
|
+
return 1
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --------------------------------------------------------------------------- #
|
|
48
|
+
# individual checks
|
|
49
|
+
# --------------------------------------------------------------------------- #
|
|
50
|
+
def check_duplicate_ids(pairs: list[tuple[int, int]], kind: str) -> list[Problem]:
|
|
51
|
+
"""ERROR per id that appears more than once (writer MUST NOT emit duplicates;
|
|
52
|
+
readers last-wins, so the earlier timestamps are silently lost)."""
|
|
53
|
+
counts: dict[int, int] = {}
|
|
54
|
+
order: list[int] = []
|
|
55
|
+
for key, _ in pairs:
|
|
56
|
+
if key not in counts:
|
|
57
|
+
order.append(key)
|
|
58
|
+
counts[key] = counts.get(key, 0) + 1
|
|
59
|
+
problems: list[Problem] = []
|
|
60
|
+
for key in order:
|
|
61
|
+
if counts[key] > 1:
|
|
62
|
+
problems.append(
|
|
63
|
+
Problem(
|
|
64
|
+
ERROR,
|
|
65
|
+
f"{kind}.duplicate_id",
|
|
66
|
+
f"id {key} appears {counts[key]} times in the {kind} sidecar; a writer "
|
|
67
|
+
f"MUST NOT emit duplicates (readers take last-wins, losing the rest).",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return problems
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_dangling_ids(keys: list[int], valid_ids: set[int], kind: str) -> list[Problem]:
|
|
74
|
+
"""WARNING if the sidecar references ids absent from the model.
|
|
75
|
+
|
|
76
|
+
Worded to name the real hazard: after an SfM re-run the dangerous case is not the id
|
|
77
|
+
that vanished but the one that still exists yet now points at a *different* image —
|
|
78
|
+
a silently mislabeled timestamp. A dangling id is often the only visible symptom.
|
|
79
|
+
"""
|
|
80
|
+
dangling = sorted({k for k in keys if k not in valid_ids})
|
|
81
|
+
if not dangling:
|
|
82
|
+
return []
|
|
83
|
+
sample = ", ".join(str(k) for k in dangling[:5]) + (" …" if len(dangling) > 5 else "")
|
|
84
|
+
return [
|
|
85
|
+
Problem(
|
|
86
|
+
WARNING,
|
|
87
|
+
f"{kind}.dangling_id",
|
|
88
|
+
f"{len(dangling)} id(s) in the {kind} sidecar are not in the model ({sample}). "
|
|
89
|
+
f"If SfM was re-run, ids may be globally misaligned — surviving ids can now "
|
|
90
|
+
f"point at different entities, silently mislabeling timestamps. Regenerate the "
|
|
91
|
+
f"sidecar from the current model rather than trusting the survivors.",
|
|
92
|
+
)
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check_time_meta_present(times: dict[int, int], time_meta: dict | None) -> list[Problem]:
|
|
97
|
+
"""WARNING if a ``times`` sidecar exists but ``time_meta`` is absent (spec I.C). Without it
|
|
98
|
+
the timestamps have no declared clock domain / exposure convention and are only a relative
|
|
99
|
+
axis; a producer SHOULD declare provenance."""
|
|
100
|
+
if times and time_meta is None:
|
|
101
|
+
return [
|
|
102
|
+
Problem(
|
|
103
|
+
WARNING,
|
|
104
|
+
"time_meta.absent",
|
|
105
|
+
"times sidecar present but time_meta.json is absent; timestamps have undeclared "
|
|
106
|
+
"semantics (relative time only — MUST NOT be compared across models or to "
|
|
107
|
+
"wall-clock). A producer SHOULD write time_meta declaring clock_domain and "
|
|
108
|
+
"time_convention.",
|
|
109
|
+
)
|
|
110
|
+
]
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def check_device_camera_ids_unique(time_meta: dict | None) -> list[Problem]:
|
|
115
|
+
"""ERROR if a CAMERA_ID is attributed to more than one device (spec Part I: one
|
|
116
|
+
image cannot be timestamped by two clocks). Absent time_meta/devices/camera_ids is OK.
|
|
117
|
+
"""
|
|
118
|
+
if not time_meta:
|
|
119
|
+
return []
|
|
120
|
+
devices = time_meta.get("devices")
|
|
121
|
+
if not isinstance(devices, dict):
|
|
122
|
+
return []
|
|
123
|
+
problems: list[Problem] = []
|
|
124
|
+
seen: dict[int, str] = {}
|
|
125
|
+
for device_id, dev in devices.items():
|
|
126
|
+
if not isinstance(dev, dict):
|
|
127
|
+
continue
|
|
128
|
+
for cam_id in dev.get("camera_ids", []) or []:
|
|
129
|
+
prior = seen.get(cam_id)
|
|
130
|
+
if prior is not None and prior != device_id:
|
|
131
|
+
problems.append(
|
|
132
|
+
Problem(
|
|
133
|
+
ERROR,
|
|
134
|
+
"time_meta.camera_id_conflict",
|
|
135
|
+
f"CAMERA_ID {cam_id} is attributed to multiple devices "
|
|
136
|
+
f"({prior!r} and {device_id!r}); a camera belongs to one clock.",
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
seen[cam_id] = device_id
|
|
141
|
+
return problems
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --------------------------------------------------------------------------- #
|
|
145
|
+
# orchestration
|
|
146
|
+
# --------------------------------------------------------------------------- #
|
|
147
|
+
def validate(
|
|
148
|
+
model_dir: str | Path,
|
|
149
|
+
*,
|
|
150
|
+
image_ids: set[int] | None = None,
|
|
151
|
+
point_ids: set[int] | None = None,
|
|
152
|
+
) -> list[Problem]:
|
|
153
|
+
"""Run all available checks over a model directory and return graded problems.
|
|
154
|
+
|
|
155
|
+
Duplicate-id and device/camera checks need no base model. Dangling-id checks run only
|
|
156
|
+
when the caller supplies the model's id sets (``image_ids`` / ``point_ids``); wiring
|
|
157
|
+
those from the base model via pycolmap belongs to :mod:`colmap4d.model` (not yet built).
|
|
158
|
+
Compute process status with :func:`exit_code`.
|
|
159
|
+
"""
|
|
160
|
+
d = Path(model_dir)
|
|
161
|
+
problems: list[Problem] = []
|
|
162
|
+
|
|
163
|
+
for bin_name, txt_name, kind, valid in (
|
|
164
|
+
(sidecar.TIMES_BIN, sidecar.TIMES_TXT, "times", image_ids),
|
|
165
|
+
(sidecar.POINTS_T_BIN, sidecar.POINTS_T_TXT, "points_t", point_ids),
|
|
166
|
+
):
|
|
167
|
+
path = (
|
|
168
|
+
d / bin_name
|
|
169
|
+
if (d / bin_name).exists()
|
|
170
|
+
else (d / txt_name if (d / txt_name).exists() else None)
|
|
171
|
+
)
|
|
172
|
+
if path is None:
|
|
173
|
+
continue
|
|
174
|
+
pairs = (
|
|
175
|
+
sidecar.read_times_pairs(path) if kind == "times" else sidecar.read_points_t_pairs(path)
|
|
176
|
+
)
|
|
177
|
+
problems += check_duplicate_ids(pairs, kind)
|
|
178
|
+
if valid is not None:
|
|
179
|
+
problems += check_dangling_ids([k for k, _ in pairs], valid, kind)
|
|
180
|
+
|
|
181
|
+
sc = sidecar.load_sidecars(d)
|
|
182
|
+
problems += check_time_meta_present(sc.times, sc.time_meta)
|
|
183
|
+
problems += check_device_camera_ids_unique(sc.time_meta)
|
|
184
|
+
return problems
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: colmap4d
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Standard COLMAP sparse model + per-image and per-point timestamps (x, y, z, t).
|
|
5
|
+
Author: colmap4d authors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/Kiri-Innovation/colmap4d
|
|
8
|
+
Project-URL: Repository, https://github.com/Kiri-Innovation/colmap4d
|
|
9
|
+
Project-URL: Specification, https://github.com/Kiri-Innovation/colmap4d/blob/main/spec/colmap4d-v1.0.md
|
|
10
|
+
Project-URL: Changelog, https://github.com/Kiri-Innovation/colmap4d/blob/main/spec/CHANGELOG.md
|
|
11
|
+
Keywords: colmap,sfm,4d,dynamic-scene,gaussian-splatting
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Intended Audience :: Science/Research
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
21
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Provides-Extra: model
|
|
27
|
+
Requires-Dist: pycolmap>=0.6; extra == "model"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# colmap4d
|
|
34
|
+
|
|
35
|
+
**A standard COLMAP sparse model + one timestamp per image + one timestamp per point.**
|
|
36
|
+
Every image and every point becomes an independent sample in spacetime `(x, y, z, t)`.
|
|
37
|
+
Nothing else: frame sync is just timestamps being close, and any discretization is a
|
|
38
|
+
derived view or a render parameter, never stored data.
|
|
39
|
+
|
|
40
|
+
colmap4d adds up to three **sidecar** files to an ordinary COLMAP model and changes no
|
|
41
|
+
existing file — so old tools open it as a perfectly normal (static) reconstruction, while a
|
|
42
|
+
4D-aware viewer unlocks a free 3D view + a draggable time axis.
|
|
43
|
+
|
|
44
|
+
> Spec **v1.0 — frozen.** See [`spec/colmap4d-v1.0.md`](spec/colmap4d-v1.0.md); Part I and the
|
|
45
|
+
> conformance goldens are normative, and changes follow [`spec/CHANGELOG.md`](spec/CHANGELOG.md).
|
|
46
|
+
>
|
|
47
|
+
> colmap4d is an **independent, community extension** of the COLMAP output format. It is **not
|
|
48
|
+
> affiliated with or endorsed by** the COLMAP project; "COLMAP" is used only to name the format
|
|
49
|
+
> it extends.
|
|
50
|
+
|
|
51
|
+
## 30-second quickstart
|
|
52
|
+
|
|
53
|
+
Read a COLMAP model together with its time layer — **zero dependencies**:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import colmap4d
|
|
57
|
+
|
|
58
|
+
mv = colmap4d.load_model_view("path/to/sparse") # COLMAP model dir, with or without sidecars
|
|
59
|
+
|
|
60
|
+
mv.image_ids() # {1, 2, 3, ...}
|
|
61
|
+
mv.image_time(1) # -> t_ns (int64 nanoseconds), or None if no timestamp
|
|
62
|
+
mv.point_time(42) # -> t_ns, or None if the point is temporally-unbounded
|
|
63
|
+
mv.effective_times() # {image_id: t_ns} for images in the model
|
|
64
|
+
mv.effective_points_t() # {point3d_id: t_ns}, dangling ids already dropped
|
|
65
|
+
mv.sidecars.time_meta # {"clock_domain": "utc_ntp", ...} or None
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Just the timestamps, without reading the base model:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
sc = colmap4d.load_sidecars("path/to/sparse")
|
|
72
|
+
sc.times[image_id] # -> t_ns
|
|
73
|
+
sc.point_time(point3d_id) # -> t_ns, or None (temporally-unbounded)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Validate a model (graded checks, non-zero exit on ERROR):
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from colmap4d import validate_full
|
|
80
|
+
from colmap4d.validate import exit_code
|
|
81
|
+
problems = validate_full("path/to/sparse") # duplicate ids = ERROR, dangling = WARNING
|
|
82
|
+
code = exit_code(problems, strict=False) # 0 if clean
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A plain COLMAP model (no sidecars) loads fine: empty times, all points temporally-unbounded —
|
|
86
|
+
the backward-compatibility baseline.
|
|
87
|
+
|
|
88
|
+
The reader is **standard-library only**. `colmap4d.colmap_io` parses the classic COLMAP
|
|
89
|
+
txt/bin base model; where `pycolmap` is installed (optional extra `pip install
|
|
90
|
+
'colmap4d[model]'`) it is preferred for authoritative parsing and 3.12 rigs/frames support.
|
|
91
|
+
|
|
92
|
+
## Repository layout
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
spec/ the format specification (normative surface)
|
|
96
|
+
src/colmap4d/ reference implementation
|
|
97
|
+
sidecar.py zero-dep read/write of times / points_t / time_meta
|
|
98
|
+
colmap_io.py zero-dep COLMAP base-model reader/writer (classic txt/bin)
|
|
99
|
+
model.py base model + sidecars join (ModelView, load_model_view, validate_full)
|
|
100
|
+
convert/ importers: the format's "writer" side
|
|
101
|
+
per_frame_colmap.py N per-frame COLMAP dirs -> one colmap4d model
|
|
102
|
+
validate.py graded checks (duplicate=ERROR, dangling=WARNING) + exit codes
|
|
103
|
+
groups.py derived pseudo-frame grouping (placeholder)
|
|
104
|
+
conformance/ hand-authored golden models + tests (the executable spec)
|
|
105
|
+
docs/ open questions, notes
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Viewer
|
|
109
|
+
|
|
110
|
+
The interactive 4D viewer is a **separate project** (different stack, heavier build,
|
|
111
|
+
faster release cadence). Today that is the `ColmapUtil` React viewer living alongside this
|
|
112
|
+
repo (`../ColmapUtil`); it already opens standard COLMAP models and is being extended for
|
|
113
|
+
the colmap4d time axis. It will be linked here once published under the org.
|
|
114
|
+
|
|
115
|
+
## Versioning
|
|
116
|
+
|
|
117
|
+
Two **independent** version axes:
|
|
118
|
+
|
|
119
|
+
| axis | where | meaning |
|
|
120
|
+
|------|-------|---------|
|
|
121
|
+
| **Package version** | `pyproject.toml` (`1.0.1`) | the Python implementation — bumps for any code change (bug fixes, new converters, API tweaks). |
|
|
122
|
+
| **Spec version** | `spec/` title + `time_meta.colmap4d_spec` (`1.0`) | the on-disk **protocol** — bumps only on a protocol change. |
|
|
123
|
+
|
|
124
|
+
They move at different rates: the package can release many times against one frozen spec.
|
|
125
|
+
**This package (1.0.1) implements spec v1.0.** Protocol changes are logged in
|
|
126
|
+
[`spec/CHANGELOG.md`](spec/CHANGELOG.md).
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
pip install ruff pytest
|
|
132
|
+
ruff check . && ruff format --check .
|
|
133
|
+
pytest # runs the conformance suite
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
[Apache-2.0](LICENSE).
|