File size: 20,796 Bytes
6628826 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 | """Import a real Stringman room from a naavox LeRobot dataset into the twin: geometry, named places, the floor image.
hf download naavox/playroom-sep29-2 --repo-type dataset --local-dir data/hf/playroom-sep29-2
python scripts/import_room.py --dataset data/hf/playroom-sep29-2 --conf conf_playroom --calibrations <fw>/src/nf_robot/ml/calibrations --name playroom
python scripts/import_room.py --dataset data/hf/bedroom-sep6 --conf conf_bedroom_move_clutter_2 --name bedroom
uv run python scripts/import_room.py --building commercial_real_estate --office r5 --name cre_r5
A BUILDING (the room layer, milestone R, 2026-10-13): one office cut out of a scanned building
(~/robot-lab/buildings/<name>/, shared by every robot; see import_building's docstring). Writes configs/rooms/<name>.yaml
and data/rooms/<name>/ (visual meshes, the fixed map's points; outside git, rebuilt by this command).
Writes configs/rooms/<name>.yaml and assets/rooms/<name>/floor.png. Everything comes from the recording itself, in its
own room frame (fw ml/calibrations/readme.md: a config is only valid for the sessions it was recorded with):
- anchors: the `anchor_poses` feature (datasets from August 2026 on carry it per frame; constant within a session)
- named places: observation.state `<place>_bearing` / `_distance`, which are in the GRIPPER frame:
place = gripper_pos_xy + distance * (sin(a), cos(a)), a = bearing + spin
(found 2026-10-08 by trying the conventions: this one puts every frame of playroom-sep29-2 on the same point, std
0.00 m; the others scatter by 0.6-1.8 m). A place whose solution is the origin is unset.
- floor: the median of the episodes' first overhead frames. The overhead camera is the firmware's orthographic floor
view (host/floor_view.py: room origin at the image centre, +y up, 5 m across), so it drops onto the twin's floor
at its true scale. Static clutter stays in it: it is the room's look, not something to pick.
- eyelets (the indirect lines' far corners) are NOT in the recording: taken from the room's calibration file
(fw ml/calibrations) through the 2D similarity that maps that file's two anchors onto the recording's, z = the
recording's anchor height. APPROXIMATE (that file is an older calibration of the same room).
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
import numpy as np
import yaml
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
# the firmware's calibration files (cranebot3-firmware src/nf_robot/ml/calibrations): --calibrations or $NF_CALIBRATIONS
FW_CAL = Path(os.environ.get("NF_CALIBRATIONS", "nf_robot/ml/calibrations"))
PLACES = ("hamper", "toybox", "trashcan", "gamepad", "parking_location")
EXTENT_M = 5.0 # fw host/floor_view.EXTENT_M
def similarity_2d(src: np.ndarray, dst: np.ndarray):
"""Scale, rotation, translation mapping 2 src points onto 2 dst points (complex-number form)."""
s0, s1 = complex(*src[0]), complex(*src[1])
d0, d1 = complex(*dst[0]), complex(*dst[1])
a = (d1 - d0) / (s1 - s0)
b = d0 - a * s0
return lambda p: np.array([(a * complex(*p) + b).real, (a * complex(*p) + b).imag]), abs(a)
def floor_median(ds: Path, n_max: int = 60) -> np.ndarray:
import av
import pandas as pd
ep = pd.read_parquet(ds / "meta/episodes/chunk-000/file-000.parquet")
col = "videos/observation.images.overhead_camera/from_timestamp"
fidx = "videos/observation.images.overhead_camera/file_index"
ts = sorted(ep.loc[ep[fidx] == 0, col].values[:n_max] + 0.5)
c = av.open(str(ds / "videos/observation.images.overhead_camera/chunk-000/file-000.mp4"))
s = c.streams.video[0]
s.thread_type = "AUTO"
out, i = [], 0
for f in c.decode(s):
t = float(f.pts * s.time_base)
while i < len(ts) and t >= ts[i]:
out.append(f.to_ndarray(format="rgb24"))
i += 1
if i >= len(ts):
break
return np.median(np.stack(out), 0).astype(np.uint8), len(out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dataset")
ap.add_argument("--conf", help="fw ml/calibrations/<conf>.json for the eyelets")
ap.add_argument("--calibrations", help="the firmware's ml/calibrations folder (default $NF_CALIBRATIONS)")
ap.add_argument("--building", help="a scanned building (~/robot-lab/buildings/<name>): import one office of it")
ap.add_argument("--office", help="the office's id in building.yaml (e.g. r5)")
ap.add_argument("--anchor-z", type=float, default=2.65, help="mount height: ceiling 2.85 less the winch block")
ap.add_argument("--name", required=True)
args = ap.parse_args()
if args.building:
return import_building(args)
import pandas as pd
ds = Path(args.dataset)
info = json.loads((ds / "meta/info.json").read_text())
df = pd.read_parquet(ds / "data/chunk-000/file-000.parquet")
names = info["features"]["observation.state"]["names"]
S = np.stack(df["observation.state"].values)
col = lambda k: S[:, names.index(k)] # noqa: E731
ap_ = np.stack(df["anchor_poses"].values)
assert len(np.unique(ap_.round(4), axis=0)) == 1, "anchors moved within the recording"
a = ap_[0].reshape(2, 6) # rx, ry, rz, x, y, z per anchor
anchors = a[:, 3:6]
places = {}
g = np.c_[col("gripper_pos_x"), col("gripper_pos_y")]
for p in PLACES:
ang = col(f"{p}_bearing") + col("spin")
P = g + col(f"{p}_distance")[:, None] * np.c_[np.sin(ang), np.cos(ang)]
med, sd = np.median(P, 0), P.std(0).max()
if sd < 0.02 and np.linalg.norm(med) > 0.05:
places[p] = [round(float(med[0]), 3), round(float(med[1]), 3)]
conf = json.loads((Path(args.calibrations or FW_CAL) / f"{args.conf}.json").read_text())
ca = np.array([[x["pose"]["position"][k] if "pose" in x else x["position"][k] for k in "xy"] for x in conf["anchors"]])
ce = np.array([[x["indirectLine"]["eyeletPos"][k] for k in "xy"] for x in conf["anchors"]])
f, scale = similarity_2d(ca, anchors[:, :2])
eyelets = np.array([f(e) for e in ce])
z = anchors[:, 2]
# line order (fw-sim): 0 anchor0 direct, 1 anchor0 indirect via its eyelet, 2 anchor1 direct, 3 anchor1 indirect
mounts = [anchors[0, :2], eyelets[0], anchors[1, :2], eyelets[1]]
mz = [z[0], z[0], z[1], z[1]]
runs = [0.0, float(np.linalg.norm(eyelets[0] - anchors[0, :2])), 0.0, float(np.linalg.norm(eyelets[1] - anchors[1, :2]))]
img, n = floor_median(ds)
out_dir = ROOT / "assets/rooms" / args.name
out_dir.mkdir(parents=True, exist_ok=True)
from PIL import Image
Image.fromarray(img).save(out_dir / "floor.png")
gan = np.c_[col("gantry_position_x"), col("gantry_position_y"), col("gantry_position_z")]
room = {
"source": {"dataset": f"naavox/{ds.name}", "frames": int(len(df)), "conf_for_eyelets": args.conf,
"eyelet_similarity_scale": round(float(scale), 3), "floor_median_of": int(n)},
"mounts_xyz": [[round(float(m[0]), 3), round(float(m[1]), 3), round(float(zz), 3)] for m, zz in zip(mounts, mz)],
"indirect_run_m": [round(r, 3) for r in runs],
"ceiling_m": round(float(z.max()) + 0.13, 3), # docs: anchors ~13 cm below the ceiling
"named_places": places,
"floor_image": {"path": f"assets/rooms/{args.name}/floor.png", "extent_m": EXTENT_M, "centre": [0.0, 0.0]},
"recorded_gantry_box": [[round(float(gan[:, i].min()), 2), round(float(gan[:, i].max()), 2)] for i in range(3)],
"anchor_yaw_rad": [round(float(a[0, 2]), 3), round(float(a[1, 2]), 3)],
}
path = ROOT / "configs/rooms" / f"{args.name}.yaml"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"# Imported by scripts/import_room.py from naavox/{ds.name} (see its docstring for each field's source).\n"
+ yaml.safe_dump(room, sort_keys=False))
print(path.read_text())
# ----------------------------------------------------------------------------------------------- a scanned building
def floor_field(P, box, fz, cell=0.25):
"""The scanned floor's height as a smooth field f(x, y): the scan is not flat. r5's floor points wander from -6.7 to
+1.0 cm (0.6 m cell medians, 2026-10-13; a RANSAC plane came out 1.05 deg tilted and left the open floor at
+1.7 cm median): Polycam drift, not a sloping office. Per 25 cm cell: the median of the points within 4 cm of
the cell's 10th percentile (the floor, not a chair leg), cells with < 20 such points filled from the nearest
measured ones (under the table), then smoothed (sigma 2 cells). Returns f (callable on (n, 2)), its grid."""
from scipy import ndimage
from scipy.interpolate import NearestNDInterpolator, RegularGridInterpolator
x0, x1, y0, y1 = box
Q = P[(P[:, 0] > x0) & (P[:, 0] < x1) & (P[:, 1] > y0) & (P[:, 1] < y1) & (np.abs(P[:, 2] - fz) < 0.12)]
nx, ny = int(np.ceil((x1 - x0) / cell)), int(np.ceil((y1 - y0) / cell))
ij = np.minimum(((Q[:, :2] - [x0, y0]) / cell).astype(int), [nx - 1, ny - 1])
G = np.full((nx, ny), np.nan)
key = ij[:, 0] * ny + ij[:, 1]
order = np.argsort(key)
ks, starts = np.unique(key[order], return_index=True)
for k, a, b in zip(ks, starts, np.r_[starts[1:], len(order)]):
z = Q[order[a:b], 2]
lo = np.percentile(z, 10)
z = z[z < lo + 0.04]
if len(z) >= 20:
G[k // ny, k % ny] = np.median(z)
ok = ~np.isnan(G)
ii, jj = np.nonzero(ok)
fill = NearestNDInterpolator(np.c_[ii, jj], G[ok])
ai, aj = np.nonzero(~ok)
if len(ai):
G[~ok] = fill(np.c_[ai, aj])
G = ndimage.gaussian_filter(G, 2.0, mode="nearest")
xs, ys = x0 + (np.arange(nx) + 0.5) * cell, y0 + (np.arange(ny) + 0.5) * cell
f = RegularGridInterpolator((xs, ys), G, bounds_error=False, fill_value=None)
return lambda xy: f(np.clip(xy, [xs[0], ys[0]], [xs[-1], ys[-1]])), G, float(ok.mean())
def import_building(args):
"""One office of a scanned building -> a room for the cable robot.
- mounts: the office's four corners at anchor height, found from the scan, not typed in: the free space in the
band 1.0 m .. anchor_z + 0.15 m (wall points occupy a 4 cm grid; the anchor band alone leaked out of r5
through two scan holes in its walls, 2026-10-13), the connected part around the office centre,
eroded 5 cm (fw definitions: eyelets 5 cm off the walls); the corner = the free cell furthest along
(+-1, +-1). Order as scripts/make_room3d.py (CCW from +x+y; lines 0, 2 direct, 1, 3 via eyelets along a wall).
- the room frame: origin on the floor under the mounts' centre, axes = the building's (pose in building.yaml)
- the visual mesh: the OBJ's faces in the office box (+30 cm: its walls), the ceiling dropped (the overhead
camera hangs under it), one mesh per texture. VISUAL ONLY in the plant (contype 0).
- the fixed map's points: the PLY in the same crop (data/rooms/<name>/fixed_points.npy, xyz + rgb).
- supports: the floor and each large horizontal surface (upward mesh faces 0.3-1.5 m, clustered by height and
place, >= 0.25 m^2): a table top's height and box. The plant gets a thin collision box under each so waste
can lie on it (the mesh itself has no contacts).
- the bin (named place `hamper`): free floor near a wall, >= 0.6 m inside the mount polygon, nothing of the
scan 0.1-1.5 m above within 0.45 m."""
from scipy import ndimage
from cablepilot.roommap.scan import load_building, load_mtl, load_obj, load_ply, obj_to_frame, write_obj
bdir, B = load_building(args.building)
off = B["offices"][args.office]
x0, x1, y0, y1 = off["box"]
fz = float(B["floor_z"])
P, C = load_ply(bdir / B["files"]["points"])
pad = 0.3
near = (P[:, 0] > x0 - pad) & (P[:, 0] < x1 + pad) & (P[:, 1] > y0 - pad) & (P[:, 1] < y1 + pad)
# the office's own floor, FOUND (armpilot scan_furniture: RANSAC, "found, not given"): the building's floor_z is
# the whole floor's histogram peak, and r5's floor lies 2.2 cm under it (median, 2026-10-13); a level-floor
# assumption left a speckle of "obstacle" voxels 2-4 cm up all over the room
ffield, G, measured = floor_field(P, (x0 - pad, x1 + pad, y0 - pad, y1 + pad), fz)
P = P.copy()
P[near, 2] -= ffield(P[near, :2]) # flatten (the same correction for the mesh below)
fz_mean = float(np.mean(G))
# mounts: free space at anchor height
res = 0.04
lo = np.array([x0 - pad, y0 - pad])
shape = (np.array([x1 - x0, y1 - y0]) + 2 * pad) / res
shape = shape.astype(int) + 1
band = near & (P[:, 2] > 1.0) & (P[:, 2] < args.anchor_z + 0.15)
occ = np.zeros(shape, bool)
ij = ((P[band, :2] - lo) / res).astype(int)
occ[ij[:, 0], ij[:, 1]] = True
occ = ndimage.binary_closing(occ, iterations=2) # the scan's 2.2 cm points leave pinholes in a wall
lab, _ = ndimage.label(~occ)
c_ij = ((np.array([(x0 + x1) / 2, (y0 + y1) / 2]) - lo) / res).astype(int)
free = ndimage.binary_erosion(lab == lab[tuple(c_ij)], iterations=2) # 2 cells = 8 cm from a wall point's cell
fi, fj = np.nonzero(free)
cells = lo + (np.c_[fi, fj] + 0.5) * res
M = []
for d in ((1, 1), (-1, 1), (-1, -1), (1, -1)):
M.append(cells[np.argmax(cells @ np.array(d, float))])
M = np.array(M)
O = M.mean(0).round(3) # the room frame's origin (floor under the mounts' centre)
mounts = np.c_[M - O, np.full(4, args.anchor_z)]
runs = [0.0, float(np.linalg.norm(M[1] - M[0])), 0.0, float(np.linalg.norm(M[3] - M[2]))]
out = ROOT / "data/rooms" / args.name
out.mkdir(parents=True, exist_ok=True)
keep = near & (P[:, 2] > -0.05) & (P[:, 2] < off["ceiling_m"] - 0.1)
pts = np.c_[P[keep] - [*O, 0], C[keep]].astype(np.float32)
np.save(out / "fixed_points.npy", pts)
V, T, Fc, Mt = load_obj(bdir / B["files"]["mesh"])
V = obj_to_frame(V) # the mesh shares the PLY's frame (same capture)
V[:, 2] -= ffield(V[:, :2])
V = V - [*O, 0]
A = V[Fc[:, :, 0]]
nrm = np.cross(A[:, 1] - A[:, 0], A[:, 2] - A[:, 0])
area = np.linalg.norm(nrm, axis=1) / 2
up = nrm[:, 2] / (2 * area + 1e-12)
cen = A.mean(1)
bx0, bx1, by0, by1 = x0 - O[0] - pad, x1 - O[0] + pad, y0 - O[1] - pad, y1 - O[1] + pad
inbox = (cen[:, 0] > bx0) & (cen[:, 0] < bx1) & (cen[:, 1] > by0) & (cen[:, 1] < by1)
keepF = inbox & ~((cen[:, 2] > off["ceiling_m"] - 0.25) & (up < -0.5))
mtl = load_mtl(bdir / B["files"]["mesh"].replace(" ", "_").replace(".obj", ".mtl"))
meshes = []
for k, mat in enumerate(sorted(set(Mt[keepF]))):
sel = keepF & (Mt == mat)
n = write_obj(out / f"mesh_{k}.obj", V, T, Fc[sel])
from PIL import Image # MuJoCo reads PNG textures, not the scan's JPEGs
Image.open(bdir / mtl[mat]).convert("RGB").save(out / f"mesh_{k}.png")
meshes.append({"obj": f"mesh_{k}.obj", "texture": f"mesh_{k}.png", "faces": int(n)})
# supports: large horizontal surfaces above the floor
hz = keepF & (up > 0.95) & (cen[:, 2] > 0.3) & (cen[:, 2] < 1.5)
supports = [{"name": "floor", "z": 0.0}]
g = 0.05
zb = np.round(cen[hz, 2] / 0.02).astype(int)
for zk in np.unique(zb):
s = np.nonzero(hz)[0][np.abs(zb - zk) <= 1]
if area[s].sum() < 0.25:
continue
q = ((cen[s, :2] - [bx0, by0]) / g).astype(int)
grid = np.zeros((int((bx1 - bx0) / g) + 1, int((by1 - by0) / g) + 1), bool)
grid[q[:, 0], q[:, 1]] = True
grid = ndimage.binary_closing(grid, iterations=2)
lab, nl = ndimage.label(grid)
for L in range(1, nl + 1):
mem = lab[q[:, 0], q[:, 1]] == L
if area[s[mem]].sum() < 0.25 or any(abs(sp["z"] - np.average(cen[s[mem], 2], weights=area[s[mem]])) < 0.03
and sp.get("_l") == (zk, L) for sp in supports):
continue
xy = cen[s[mem], :2]
zz = float(np.average(cen[s[mem], 2], weights=area[s[mem]]))
if any(sp["name"] != "floor" and abs(sp["z"] - zz) < 0.03 and np.all(np.abs(np.mean(xy, 0) - np.mean(sp["box"], 1)) < 0.3)
for sp in supports):
continue # the same table seen in the neighbouring height bin
bxy = np.percentile(xy, [3, 97], axis=0)
supports.append({"name": f"top{len(supports)}", "z": round(zz, 3),
"box": [[round(float(bxy[0, 0]), 3), round(float(bxy[1, 0]), 3)],
[round(float(bxy[0, 1]), 3), round(float(bxy[1, 1]), 3)]],
"area_m2": round(float(area[s[mem]].sum()), 2)})
# one table, one support: the height bins overlap (r5's table came out three times, 0.710 / 0.717 / 0.718 m,
# boxes inside each other, 2026-10-13): merge same-height (3 cm) supports whose boxes overlap into their union
tops = [sp for sp in supports if "box" in sp]
merged = True
while merged:
merged = False
for i in range(len(tops)):
for j in range(i + 1, len(tops)):
a, b = tops[i], tops[j]
(ax0, ax1), (ay0, ay1) = a["box"]
(bx0_, bx1_), (by0_, by1_) = b["box"]
if abs(a["z"] - b["z"]) < 0.03 and ax0 < bx1_ and bx0_ < ax1 and ay0 < by1_ and by0_ < ay1:
w = a["area_m2"] + b["area_m2"]
a["z"] = round((a["z"] * a["area_m2"] + b["z"] * b["area_m2"]) / w, 3)
a["box"] = [[min(ax0, bx0_), max(ax1, bx1_)], [min(ay0, by0_), max(ay1, by1_)]]
a["area_m2"] = round(max(a["area_m2"], b["area_m2"]), 2) # nested bins: the larger, not the sum
tops.pop(j)
merged = True
break
if merged:
break
supports = [supports[0]] + [{**t, "name": f"table{k}"} for k, t in enumerate(tops)]
# the bin: free floor near a wall
from cablepilot.sim.kin import ccw_polygon
poly = ccw_polygon(mounts[:, :2])
e = np.roll(poly, -1, 0) - poly
nr = np.c_[-e[:, 1], e[:, 0]] / np.linalg.norm(e, axis=1)[:, None]
tall = pts[(pts[:, 2] > 0.1) & (pts[:, 2] < 1.5), :2]
from scipy.spatial import cKDTree
tree = cKDTree(tall)
best = None
for x in np.arange(poly[:, 0].min(), poly[:, 0].max(), 0.05):
for y in np.arange(poly[:, 1].min(), poly[:, 1].max(), 0.05):
q = np.array([x, y])
ins = ((q - poly) * nr).sum(1).min()
if ins < 0.6 or tree.query(q)[0] < 0.45:
continue
if best is None or ins < best[0]: # nearest a wall (yet >= 0.6 m in)
best = (ins, q)
hamper = best[1].round(3).tolist()
room = {"source": {"building": str(bdir).replace(str(Path.home()), "~"), "office": args.office, "imported_by": "scripts/import_room.py --building",
"scan_points_kept": int(keep.sum())},
"floor_flattened": {"note": "scan z - f(x, y), f = the scanned floor's smooth height (floor_field)",
"f_range_m": [round(float(G.min()), 4), round(float(G.max()), 4)],
"cells_measured": round(measured, 2)},
"building_pose": {"xyz": [float(O[0]), float(O[1]), round(fz_mean, 4)], "yaw_rad": 0.0,
"note": "room frame = building frame shifted by xyz (floor at z 0); same axes"},
"size_m": [round(float(np.ptp(M[:, 0])), 3), round(float(np.ptp(M[:, 1])), 3)],
"mounts_xyz": mounts.round(3).tolist(), "indirect_run_m": [round(r, 3) for r in runs],
"ceiling_m": float(off["ceiling_m"]),
"named_places": {"hamper": hamper},
"scan": {"dir": f"data/rooms/{args.name}", "meshes": meshes, "points": "fixed_points.npy",
"box": [[round(float(bx0), 3), round(float(bx1), 3)], [round(float(by0), 3), round(float(by1), 3)]]},
"supports": supports}
path = ROOT / "configs/rooms" / f"{args.name}.yaml"
path.write_text(f"# One office of a scanned building ({B['name']}, office {args.office}), imported by "
f"scripts/import_room.py --building (see import_building's docstring for each field's source).\n"
+ yaml.safe_dump(room, sort_keys=False))
print(path.read_text())
if __name__ == "__main__":
main()
|