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