Files
thunderheronight--splat-wor…/scripts/combine.py
T
thunderheronightandClaude Opus 5.5 b9b60fa5a0 Rooms made of several recordings: combine, floor plan and groups (core, no UI yet)
A group can be a room (several recordings of one room); any other group may hold scenes and rooms and
is put together from the recordings, each in its room. Recordings of one room no longer count as
walking in to each other, which also hid a real walk-in from another room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:06:23 +02:00

437 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy", "scipy"]
# ///
"""Put several scenes of one recording session together into one big scene.
Usage: uv run combine.py <out folder> <run folder>=<label> [<run folder>=<label> ...]
[--align frame|images|shift|rigid] [--partition view|nearest] [--rooms JSON]
--rooms: the room of every recording, in the same order, e.g. '["Kitchen", "Kitchen", "Hallway"]' – a room
can be made of several recordings (default: every recording is a room of its own).
All recordings of one session share the Steam Frame's tracking frame, so the rooms are already
roughly in place. Steps:
1. Alignment (--align):
frame as the pipeline delivers it (aligned on the first image of each recording)
images every room fitted to the Frame's tracking over all its images (default)
shift images + a joint shift of the rooms so the overlaps (doors, passages) match
rigid images + a joint shift and slight rotation
2. Who owns which part (--partition):
view per 10 cm cube the recording that saw it from close by and looking at it; the first and
last stretch of a recording along the round of another room's recording (walking in/out)
does not count, so the borders between rooms run through doors (default). Recordings of
the same room simply share it: every part from the recording that saw it best.
nearest the recording whose camera was closest
3. Every room is transformed, cut to its own part and appended – one room in memory at a time.
Writes <out>/spirula/scene.ply (all splats), <out>/scene.xrSceneTransform.json (start view of the
room in the middle) and <out>/combine.json (settings and how well the overlaps match).
Lines starting with "@@" are progress markers for the web interface.
"""
import argparse
import itertools
import json
import shutil
import struct
import sys
import time
from pathlib import Path
import numpy as np
from scipy.spatial import cKDTree
from scipy.spatial.transform import Rotation
VOXEL, RADIUS, K = 0.10, 1.5, 64 # ownership: cube size, camera reach, cameras looked at
BOTH = 1.5 # an overlap: places within this distance of both recordings' cameras
def progress(pct):
print(f"@@ pct {int(pct)}", flush=True)
# ---------------------------------------------------------------- reading
def read_cameras(path: Path):
"""Image names, camera centres, viewing directions (COLMAP: the camera looks along +z)."""
names, centres, fwd = [], [], []
with open(path, "rb") as f:
n, = struct.unpack("<Q", f.read(8))
for _ in range(n):
_, qw, qx, qy, qz, tx, ty, tz, _ = struct.unpack("<i7di", f.read(64))
name = b""
while (ch := f.read(1)) != b"\0":
name += ch
k, = struct.unpack("<Q", f.read(8))
f.seek(24 * k, 1)
R = Rotation.from_quat([qx, qy, qz, qw]).as_matrix()
names.append(name.decode().replace("\\", "/"))
centres.append(-R.T @ np.array([tx, ty, tz]))
fwd.append(R.T[:, 2])
return names, np.array(centres), np.array(fwd)
def read_points(path: Path):
"""Sparse points with a small reprojection error."""
with open(path, "rb") as f:
n, = struct.unpack("<Q", f.read(8))
xyz, err = np.empty((n, 3)), np.empty(n)
for i in range(n):
_, x, y, z, _, _, _, e, k = struct.unpack("<Q3d3BdQ", f.read(51))
f.seek(8 * k, 1)
xyz[i], err[i] = (x, y, z), e
return xyz[err < 2.0]
def read_ply(path: Path):
with open(path, "rb") as f:
header = b""
while not header.endswith(b"end_header\n"):
line = f.readline()
if not line:
sys.exit(f"Not a PLY file: {path}")
header += line
props = [l.split()[-1].decode() for l in header.splitlines() if l.startswith(b"property")]
n = int(next(l for l in header.splitlines() if l.startswith(b"element vertex")).split()[-1])
data = np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
return header, data
def image_time(name: str) -> float:
"""cam0/0123-45.678901.jpg -> 45.678901 (seconds into the recording)."""
return float(name.rsplit("/", 1)[-1].split("-", 1)[1].removesuffix(".jpg"))
# ---------------------------------------------------------------- transforms
class Pose:
"""x -> s R x + t"""
def __init__(self, s=1.0, R=None, t=None):
self.s, self.R, self.t = s, np.eye(3) if R is None else R, np.zeros(3) if t is None else t
def __call__(self, p):
return (self.s * (self.R @ p.T)).T + self.t
def then(self, R, t, centre):
"""Followed by a rigid motion about `centre`."""
return Pose(self.s, R @ self.R, R @ (self.t - centre) + centre + t)
def umeyama(A, B):
"""Similarity that maps the points A onto B (least squares)."""
ma, mb = A.mean(0), B.mean(0)
a, b = A - ma, B - mb
U, S, Vt = np.linalg.svd(b.T @ a / len(A))
D = np.eye(3)
if np.linalg.det(U @ Vt) < 0:
D[2, 2] = -1
R = U @ D @ Vt
s = np.trace(np.diag(S) @ D) / a.var(0).sum()
return Pose(s, R, mb - s * R @ ma)
def quat_mul(a, b):
"""a (w, x, y, z) times every row of b."""
w1, x1, y1, z1 = a
w2, x2, y2, z2 = b.T
return np.stack([w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2, w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,
w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2, w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2], axis=1)
# ---------------------------------------------------------------- overlaps
def overlap_zones(rooms):
"""Sparse points of each pair where both recordings were (doors, passages). The two sides of a
wall are never both within reach of both, so they are not mistaken for the same surface."""
zones = {}
for a, b in itertools.combinations(rooms, 2):
ta, tb = cKDTree(rooms[a]["cams"]), cKDTree(rooms[b]["cams"])
both = lambda p: p[(ta.query(p)[0] < BOTH) & (tb.query(p)[0] < BOTH)]
za, zb = both(rooms[a]["pts"]), both(rooms[b]["pts"])
if len(za) >= 1000 and len(zb) >= 1000:
zones[(a, b)] = (za, zb)
return zones
def offset(pa, pb):
"""Shift that brings the overlap pa onto pb (robust, a few ICP steps) and how many points matched."""
tree = cKDTree(pb)
d, i = tree.query(pa, distance_upper_bound=0.08)
ok = np.isfinite(d)
if ok.sum() < 100:
return None, 0
P, Q, tot = pa[ok], pb[i[ok]], np.zeros(3)
for _ in range(15):
delta = np.median(Q - P, axis=0)
P, tot = P + delta, tot + delta
d2, i2 = tree.query(P, distance_upper_bound=0.05)
ok2 = np.isfinite(d2)
if ok2.sum() < 50:
break
P, Q = P[ok2], pb[i2[ok2]]
return tot, int(ok.sum())
def seams(rooms, zones, poses):
out = {}
for (a, b), (za, zb) in zones.items():
tot, n = offset(poses[a](za), poses[b](zb))
if tot is not None:
out[f"{a}|{b}"] = {"cm": round(float(np.linalg.norm(tot)) * 100, 1), "points": n}
return out
def joint_shift(rooms, zones, poses, fixed):
"""Shift every room (except `fixed`) so all overlaps match best together (least squares)."""
free = [l for l in rooms if l != fixed]
rows, rhs = [], []
for (a, b), (za, zb) in zones.items():
tot, n = offset(poses[a](za), poses[b](zb))
if tot is None:
continue
row = np.zeros(len(free))
if a in free:
row[free.index(a)] += 1 # (x_a + t_a) meets (x_b + t_b) when t_a - t_b = tot
if b in free:
row[free.index(b)] -= 1
rows.append(row * np.sqrt(n))
rhs.append(tot * np.sqrt(n))
if not rows:
return poses
sol, *_ = np.linalg.lstsq(np.array(rows), np.array(rhs), rcond=None)
return {l: poses[l] if l == fixed else poses[l].then(np.eye(3), sol[free.index(l)], np.zeros(3)) for l in rooms}
def joint_rigid(rooms, zones, poses, fixed, centre):
"""Small rotation + shift per room (except `fixed`) over the point pairs of all overlaps."""
free = [l for l in rooms if l != fixed]
rng = np.random.default_rng(1)
sub = {k: (za[rng.choice(len(za), min(len(za), 20000), replace=False)], zb) for k, (za, zb) in zones.items()}
for cut in (0.08, 0.06, 0.05, 0.04, 0.03, 0.03):
rows, rhs = [], []
for (a, b), (za, zb) in sub.items():
pa, pbz = poses[a](za), poses[b](zb)
d, i = cKDTree(pbz).query(pa, distance_upper_bound=cut)
ok = np.isfinite(d)
if ok.sum() < 200:
continue
P, Q = pa[ok] - centre, pbz[i[ok]] - centre
n, w = len(P), 1 / np.sqrt(ok.sum()) # every overlap counts about equally
def cross(X): # rows of -[X]x: (w x X) = cross(X) @ w
z = np.zeros(n)
return -np.stack([np.stack([z, -X[:, 2], X[:, 1]], 1), np.stack([X[:, 2], z, -X[:, 0]], 1),
np.stack([-X[:, 1], X[:, 0], z], 1)], 1)
A = np.zeros((n, 3, 6 * len(free)))
if a in free:
j = free.index(a) * 6
A[:, :, j:j + 3], A[:, :, j + 3:j + 6] = cross(P), np.eye(3)
if b in free:
j = free.index(b) * 6
A[:, :, j:j + 3], A[:, :, j + 3:j + 6] = -cross(Q), -np.eye(3)
rows.append(A.reshape(-1, 6 * len(free)) * w)
rhs.append(-(P - Q).reshape(-1) * w)
if not rows:
break
x, *_ = np.linalg.lstsq(np.vstack(rows), np.concatenate(rhs), rcond=None)
for l in free:
j = free.index(l) * 6
poses[l] = poses[l].then(Rotation.from_rotvec(x[j:j + 3]).as_matrix(), x[j + 3:j + 6], centre)
return poses
# ---------------------------------------------------------------- ownership
def owners(rooms, poses, partition):
"""Function: points -> index of the owning recording."""
labels = list(rooms)
cams = [poses[l](rooms[l]["cams"]) for l in labels]
fwd = [(poses[l].R @ rooms[l]["fwd"].T).T for l in labels]
owner = np.concatenate([np.full(len(c), i) for i, c in enumerate(cams)])
if partition == "nearest":
tree = cKDTree(np.vstack(cams))
return lambda p: owner[tree.query(p, workers=-1)[1]], {}
# walking in and out: the first and last stretch of a recording along the path of another room's
# recording ... (recordings of the same room cover the same place on purpose: that is no walking in)
n = len(cams)
room = [rooms[l]["room"] for l in labels]
elsewhere = lambda i, arrays: [a for j, a in enumerate(arrays) if room[j] != room[i]]
def edges(i, others):
if not others:
return np.zeros(len(cams[i]), bool)
inside = cKDTree(np.vstack(others)).query(cams[i])[0] < 0.4
order = np.argsort(rooms[labels[i]]["times"])
flag = np.zeros(len(order), bool)
for seq in (order, order[::-1]):
for k in seq:
if not inside[k]:
break
flag[k] = True
return flag
cand = [edges(i, elsewhere(i, cams)) for i in range(n)]
# ... counts only if another recording covered that place during its own round; otherwise two
# recordings that hand over to each other (one ends where the next starts) would cancel out
transit = []
for i in range(n):
core = [c for c in elsewhere(i, [cams[j][~cand[j]] for j in range(n)]) if len(c)]
transit.append(cand[i] & (cKDTree(np.vstack(core)).query(cams[i])[0] < 0.4) if core
else np.zeros(len(cams[i]), bool))
transit = np.concatenate(transit)
C, F, O = np.vstack(cams)[~transit], np.vstack(fwd)[~transit], owner[~transit]
tree = cKDTree(C)
def own(points):
keys = np.floor(points / VOXEL).astype(np.int64)
uniq, inv = np.unique(keys, axis=0, return_inverse=True)
centre = (uniq + 0.5) * VOXEL
lab = np.empty(len(uniq), dtype=np.int64)
for s in range(0, len(uniq), 200_000):
c = centre[s:s + 200_000]
d, j = tree.query(c, k=K, distance_upper_bound=RADIUS, workers=-1)
ok = np.isfinite(d)
j = np.where(ok, j, 0)
to = c[:, None, :] - C[j]
cos = np.einsum("nkd,nkd->nk", to, F[j]) / np.maximum(np.linalg.norm(to, axis=2), 1e-6)
w = np.where(ok & (cos > 0.3), (RADIUS - np.where(ok, d, RADIUS)) * cos, 0.0)
score = np.stack([(w * (O[j] == r)).sum(1) for r in range(n)], axis=1)
best = score.argmax(1)
none = score.max(1) == 0
if none.any():
best[none] = O[tree.query(c[none], workers=-1)[1]]
lab[s:s + 200_000] = best
return lab[inv.ravel()]
return own, {labels[i]: int(t.sum()) for i, t in enumerate(np.split(transit, np.cumsum([len(c) for c in cams])[:-1]))}
# ---------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("out")
ap.add_argument("runs", nargs="+", help="run folder=label")
ap.add_argument("--align", choices=("frame", "images", "shift", "rigid"), default="images")
ap.add_argument("--partition", choices=("view", "nearest"), default="view")
ap.add_argument("--rooms", default="", help="JSON list: the room of every recording (default: one each)")
args = ap.parse_args()
room_of = json.loads(args.rooms) if args.rooms else []
if room_of and (not isinstance(room_of, list) or len(room_of) != len(args.runs)
or not all(isinstance(r, str) and r for r in room_of)):
sys.exit("--rooms needs one room name per recording")
out = Path(args.out)
(out / "spirula").mkdir(parents=True, exist_ok=True)
started = time.time()
print("@@ cphase 0", flush=True)
rooms = {}
for i, arg in enumerate(args.runs):
run, _, label = arg.partition("=")
run = Path(run)
label = label or run.name
if label in rooms:
label = f"{label} ({i + 1})"
aiscene = json.loads((run / "dataset/main.aiscene").read_text())
recorded = {f["path"].removeprefix("images/"): np.array(f["worldFromCam"])[:3, 3] for f in aiscene["frames"]}
names, centres, fwd = read_cameras(run / "dataset/sparse/0/images.bin")
both = [k for k, n in enumerate(names) if n in recorded]
if len(both) < 10:
sys.exit(f"{label}: too few cameras with Frame tracking – was it made with the Arcturus pipeline?")
rooms[label] = {"run": run, "room": room_of[i] if room_of else label, "cams": centres[both], "fwd": fwd[both],
"times": np.array([image_time(names[k]) for k in both]),
"tracked": np.array([recorded[names[k]] for k in both]),
"pts": read_points(run / "dataset/sparse/0/points3D.bin")}
progress(30 * (i + 1) / len(args.runs))
# how far each room's reconstruction is from the Frame's tracking, as delivered and fitted
fit = {l: umeyama(r["cams"], r["tracked"]) for l, r in rooms.items()}
rms = lambda p, r: float(np.sqrt((np.linalg.norm(p(r["cams"]) - r["tracked"], axis=1) ** 2).mean()))
room_info = {l: {"run": str(r["run"]), "room": r["room"], "cameras": len(r["cams"]), "tracking_cm": round(rms(Pose(), r) * 100, 1),
"fitted_cm": round(rms(fit[l], r) * 100, 1)} for l, r in rooms.items()}
# From here on everything is in the fitted frame; `poses` are applied on top of it.
for l, r in rooms.items():
r["cams"], r["pts"] = fit[l](r["cams"]), fit[l](r["pts"])
r["fwd"] = (fit[l].R @ r["fwd"].T).T
poses = {l: Pose() for l in rooms}
if args.align == "frame": # undo the fit: as the pipeline delivered it
poses = {l: Pose(1 / fit[l].s, fit[l].R.T, -fit[l].R.T @ fit[l].t / fit[l].s) for l in rooms}
zones = overlap_zones(rooms)
progress(55)
# one recording session: every room has to overlap with the others (one connected group)
links = {l: set() for l in rooms}
for a, b in zones:
links[a].add(b)
links[b].add(a)
seen, todo = set(), [next(iter(rooms))]
while todo:
l = todo.pop()
if l not in seen:
seen.add(l)
todo += list(links[l])
if len(rooms) > 1 and seen != set(rooms):
lone = ", ".join(sorted(set(rooms) - seen))
print(f"@@ error not_connected {lone}", flush=True)
sys.exit(f"These scenes do not touch the others: {lone}. Only rooms of one recording session "
"(without restarting the Frame in between) share one frame and can be put together.")
hub = max(rooms, key=lambda l: (len(links[l]), sum(len(zones[k][0]) for k in zones if l in k)))
before = seams(rooms, zones, poses)
centre = np.vstack([r["cams"] for r in rooms.values()]).mean(0)
if args.align == "shift":
poses = joint_shift(rooms, zones, poses, hub)
elif args.align == "rigid":
poses = joint_rigid(rooms, zones, poses, hub, centre)
after = seams(rooms, zones, poses)
# from the room's own frame: first the fit, then the poses
final = {l: Pose(poses[l].s * fit[l].s, poses[l].R @ fit[l].R, poses[l](fit[l].t)) for l in rooms}
progress(100)
print("@@ cphase 1", flush=True)
own, transit = owners(rooms, poses, args.partition)
body = out / "spirula" / "scene.body.tmp"
total, kept, header = 0, {}, None
with open(body, "wb") as fb:
for i, (l, r) in enumerate(rooms.items()):
header, v = read_ply(r["run"] / "spirula" / "scene.ply")
xyz = final[l](np.stack([v["x"], v["y"], v["z"]], axis=1).astype(np.float64))
keep = own(xyz) == i
v, xyz = v[keep], xyz[keep]
v["x"], v["y"], v["z"] = xyz.T.astype(np.float32)
q = Rotation.from_matrix(final[l].R).as_quat() # (x, y, z, w)
rot = quat_mul(np.array([q[3], q[0], q[1], q[2]]),
np.stack([v["rot_0"], v["rot_1"], v["rot_2"], v["rot_3"]], axis=1).astype(np.float64))
v["rot_0"], v["rot_1"], v["rot_2"], v["rot_3"] = rot.T.astype(np.float32)
for k in ("scale_0", "scale_1", "scale_2"):
v[k] += np.float32(np.log(final[l].s))
v.tofile(fb)
total += len(v)
kept[l] = {"splats": int(keep.size), "kept": int(keep.sum())}
print(f"{l}: {keep.sum():,} of {keep.size:,} splats kept", flush=True)
del v, xyz, rot, keep
progress(100 * (i + 1) / len(rooms))
lines = [b"element vertex %d\n" % total if l.startswith(b"element vertex") else l
for l in header.splitlines(keepends=True)]
with open(out / "spirula" / "scene.ply", "wb") as fo:
fo.write(b"".join(lines))
with open(body, "rb") as fb:
shutil.copyfileobj(fb, fo, 16 << 20)
body.unlink()
# start view: the one of the room in the middle
shutil.copy(rooms[hub]["run"] / "scene.xrSceneTransform.json", out / "scene.xrSceneTransform.json")
for l in rooms:
room_info[l].update(kept[l], walk_in_out_cameras=transit.get(l, 0))
result = {"align": args.align, "partition": args.partition, "hub": hub, "splats": total,
"rooms": room_info, "seams_before": before, "seams": after,
"seconds": round(time.time() - started)}
(out / "combine.json").write_text(json.dumps(result, ensure_ascii=False, indent=1))
worst = max(after.values(), key=lambda s: s["cm"]) if after else None
print(f"@@ total {total}", flush=True)
print(f"{total:,} splats; overlaps match within "
+ (f"{np.median([s['cm'] for s in after.values()]):.1f} cm (median), worst {worst['cm']} cm" if worst else "–"),
flush=True)
if __name__ == "__main__":
main()