Files
spoopyghosty0--frameport/tests/test_roomscan.py
T

624 lines
31 KiB
Python

"""The hosted build runs exactly the reconstruction sources shipped in fp_roomscan."""
from __future__ import annotations
import importlib.util
import json
import math
import re
import shutil
import struct
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("synth_scan", ROOT / "scripts/mr/synth_scan.py")
synth = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(synth)
@pytest.fixture(scope="module")
def scanner(tmp_path_factory):
compiler = shutil.which("cc") or shutil.which("clang")
if not compiler:
pytest.skip("roomscan tests need a host C compiler")
binary = tmp_path_factory.mktemp("roomscan-build") / "fp_roomscan"
subprocess.run([compiler, "-std=c11", "-O2", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off",
"-fno-math-errno", "-DFP_HOSTED",
*map(str, sorted((ROOT / "native/roomscan").glob("*.c"))), "-o", str(binary)],
check=True, capture_output=True, text=True)
return binary
@pytest.fixture(scope="module")
def dumps(tmp_path_factory):
directory = tmp_path_factory.mktemp("depth-meshes")
cases = {"box": {}, "l": {"shape": "l"}, "down": {"no_ceiling": True}, "one": {"frames": 1},
"rotated": {"yaw": math.radians(27), "offset": (.3, .1, -.2)},
"missing": {"missing_wall": 1, "objects": False}}
result = {}
for name, options in cases.items():
result[name] = directory / f"{name}.fprs"
synth.render(result[name], profile="legacy", **options)
return result
def run(scanner, dump, directory, *args, success=True):
proc = subprocess.run([str(scanner), "--offline", str(dump), "--out", str(directory), "--json", *args],
text=True, capture_output=True, timeout=15)
assert proc.returncode == (0 if success else 2), proc.stdout + proc.stderr
assert len(proc.stdout.splitlines()) == 1
report = json.loads(proc.stdout)
assert report["ok"] is success
return report
def room(directory, name="room.txt"):
records = {}
lines = (directory / name).read_text().splitlines()
assert lines[0] == "frameport-room 1"
for line in lines:
if not line or line.startswith("#"):
continue
kind, *values = line.split()
records.setdefault(kind, []).append(values)
return records
def outline(records):
n, *values = records["outline"][0]
assert len(values) == 2 * int(n)
values = list(map(float, values))
return list(zip(values[::2], values[1::2], strict=False))
def area(polygon):
return sum(ax * bz - bx * az for (ax, az), (bx, bz) in zip(polygon, polygon[1:] + polygon[:1], strict=False)) / 2
def assert_walls(polygon, expected):
assert len(polygon) == len(expected)
assert area(polygon) > 0
# Corner positions and segment orientation are checked independently.
for x, z in expected:
assert min(math.hypot(x - a, z - b) for a, b in polygon) < .05
expected_angles = [math.atan2(bz - az, bx - ax)
for (ax, az), (bx, bz) in zip(expected, expected[1:] + expected[:1], strict=False)]
for a, b in zip(polygon, polygon[1:] + polygon[:1], strict=False):
heading = math.atan2(b[1] - a[1], b[0] - a[0])
assert min(abs(math.atan2(math.sin(heading - e), math.cos(heading - e)))
for e in expected_angles) < math.radians(2)
dx, dz = b[0] - a[0], b[1] - a[1]
length = math.hypot(dx, dz)
assert synth.inside((a[0] + b[0]) / 2 - .04 * dz / length,
(a[1] + b[1]) / 2 + .04 * dx / length, polygon)
def mesh(directory, name="room-mesh.bin"):
data = (directory / name).read_bytes()
magic, version, nv, ni = struct.unpack_from("<4sIII", data)
assert (magic, version) == (b"FPRM", 1)
assert 0 < ni // 3 <= 200_000 and ni % 3 == 0
assert len(data) == 16 + nv * 12 + ni * 4
vertices = list(struct.iter_unpack("<fff", data[16:16 + nv * 12]))
indices = struct.unpack_from(f"<{ni}I", data, 16 + nv * 12)
assert all(math.isfinite(c) for vertex in vertices for c in vertex)
assert max(indices) < nv
return vertices, indices
def test_box_room_geometry_furniture_and_wire_format(scanner, dumps, tmp_path):
report = run(scanner, dumps["box"], tmp_path, "--universe", "18446744073709551615")
assert report["walls"] == report["detected_walls"] == 4
assert all(w == "out-of-range depth vertices skipped" for w in report["warnings"])
assert report["coverage_percent"] == 100
assert abs(report["floor_y"]) < .05
assert abs(report["ceiling_y"] - 2.6) < .05
# The scanner's own output is kept as the raw files; room.txt + room-mesh.bin are refined (tests further down).
records = room(tmp_path, "room-raw.txt")
assert records["universe"] == [["18446744073709551615"]]
assert records["source"] == [["scan"]]
assert_walls(outline(records), synth.BOX_OUTLINE)
tables = [v for v in records["volume"] if v[1] == "TABLE"]
assert len(tables) == 1
_, _, x, z, top, yaw, width, depth, height = tables[0]
assert abs(float(x) + .6) < .07 and abs(float(z) + .25) < .07
assert abs(float(top) - .75) < .05
assert abs(float(width) - 1.2) < .08 and abs(float(depth) - .7) < .08
assert abs(float(height) - .75) < .08
assert abs(float(yaw)) < 2
assert any(v[1] == "COUCH" for v in records["volume"])
ids = [row[0] for kind in ("room", "floor", "ceiling", "wall", "volume", "mesh") for row in records[kind]]
assert len(ids) == len(set(ids))
assert all(re.fullmatch("[0-9a-f]{32}", value) for value in ids)
vertices, indices = mesh(tmp_path, "room-mesh-raw.bin")
assert records["mesh"][0][1:] == ["room-mesh-raw.bin", str(len(vertices)), str(len(indices))]
refined = room(tmp_path)
assert [row[0] for row in refined["room"] + refined["floor"] + refined["wall"]] == \
[row[0] for row in records["room"] + records["floor"] + records["wall"]] # same anchors as the raw room
assert refined["mesh"][0][1:3] == ["room-mesh.bin", str(len(mesh(tmp_path)[0]))]
# Normals on the structural surfaces must face the room interior.
correct = checked = 0
for i in range(0, len(indices), 3):
a, b, c = [vertices[j] for j in indices[i:i + 3]]
centre = [sum(coords) / 3 for coords in zip(a, b, c, strict=False)]
ab, ac = [v - u for u, v in zip(a, b, strict=False)], [v - u for u, v in zip(a, c, strict=False)]
normal = (ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2],
ab[0] * ac[1] - ab[1] * ac[0])
x, y, z = centre
component = None
if abs(y) < .03:
component = normal[1]
elif abs(y - 2.6) < .03:
component = -normal[1]
elif abs(abs(x) - 2) < .02:
component = -x * normal[0]
elif abs(abs(z) - 1.5) < .02:
component = -z * normal[2]
if component is not None:
checked += 1
correct += component > 0
assert checked > 5000
assert correct / checked > .99
assert all(value >= 0 for value in report["timings_ms"].values())
def test_l_shape_preserves_reentrant_corner(scanner, dumps, tmp_path):
report = run(scanner, dumps["l"], tmp_path)
assert report["detected_walls"] == 6
polygon = outline(room(tmp_path))
assert_walls(polygon, synth.L_OUTLINE)
assert abs(area(polygon) - 12) < .15
assert not synth.inside(1, 1, polygon)
def test_pose_transform_and_non_axis_aligned_walls(scanner, dumps, tmp_path):
report = run(scanner, dumps["rotated"], tmp_path)
yaw = math.radians(27)
expected = [(math.cos(yaw) * x + math.sin(yaw) * z + .3,
-math.sin(yaw) * x + math.cos(yaw) * z - .2) for x, z in synth.BOX_OUTLINE]
assert_walls(outline(room(tmp_path)), expected)
assert abs(report["floor_y"] - .1) < .05
assert abs(report["ceiling_y"] - 2.7) < .05
def test_unseen_ceiling_uses_relative_default(scanner, dumps, tmp_path):
report = run(scanner, dumps["down"], tmp_path, "--ceiling-default", "2.7")
assert "ceiling not seen" in report["warnings"]
assert abs(report["ceiling_y"] - report["floor_y"] - 2.7) < .001
def test_one_frame_warns_and_still_emits_finite_room(scanner, dumps, tmp_path):
report = run(scanner, dumps["one"], tmp_path)
assert report["frames"] == 1
assert any("few frames" in warning for warning in report["warnings"])
assert 0 <= report["coverage_percent"] <= 100
assert len(outline(room(tmp_path))) >= 3
mesh(tmp_path)
def test_missing_wall_is_closed_and_circle_hint_is_ignored(scanner, dumps, tmp_path):
hint = tmp_path / "circle.txt"
hint.write_text("".join(f"{math.cos(i * math.tau / 32)} {math.sin(i * math.tau / 32)}\n" for i in range(32)))
out = tmp_path / "out"
report = run(scanner, dumps["missing"], out, "--hint", str(hint))
assert not report["hint_used"]
assert "default circle hint ignored" in report["warnings"]
assert report["coverage_percent"] < 100
assert "INVISIBLE_WALL_FACE" in (out / "room.txt").read_text()
labels = [row[7] if len(row) == 8 else "WALL_FACE" for row in room(out)["wall"]]
assert "INVISIBLE_WALL_FACE" in labels and "WALL_FACE" in labels
assert area(outline(room(out))) > 8
def test_matching_hint_fills_missing_wall(scanner, dumps, tmp_path):
hint = tmp_path / "outline.txt"
hint.write_text("# clockwise hint is also accepted\n" + "".join(f"{x} {z}\n" for x, z in synth.BOX_OUTLINE[::-1]))
report = run(scanner, dumps["missing"], tmp_path / "out", "--hint", str(hint))
assert report["hint_used"]
assert report["coverage_percent"] < 100
assert_walls(outline(room(tmp_path / "out")), synth.BOX_OUTLINE)
@pytest.mark.parametrize("cut", [0, 3, 7, 8, 9, 51, 52, 60, -1])
def test_truncated_dump_has_no_outputs(scanner, dumps, tmp_path, cut):
data = dumps["one"].read_bytes()
dump = tmp_path / "broken.fprs"
dump.write_bytes(data[:cut])
out = tmp_path / "out"
report = run(scanner, dump, out, success=False)
assert report["error"]
assert not out.exists()
@pytest.mark.parametrize(("offset", "replacement"), [
(0, b"NOPE"), (4, struct.pack("<I", 2)), (12, struct.pack("<I", 0x7ff00000)),
(16, struct.pack("<f", float("nan"))), (40, struct.pack("<f", 50)),
(44, struct.pack("<I", 0xffffffff)), (48, struct.pack("<I", 4)),
(52, struct.pack("<f", float("inf"))),
])
def test_corrupt_dump_is_rejected_before_publishing(scanner, dumps, tmp_path, offset, replacement):
data = bytearray(dumps["one"].read_bytes())
data[offset:offset + len(replacement)] = replacement
dump = tmp_path / "bad.fprs"
dump.write_bytes(data)
out = tmp_path / "out"
out.mkdir()
# An invalid replacement must preserve a previously installed room, byte for byte.
for name in ("room.txt", "room-mesh.bin"):
(out / name).write_bytes(b"previous room")
run(scanner, dump, out, success=False)
assert {p.name: p.read_bytes() for p in out.iterdir()} == {
"room.txt": b"previous room", "room-mesh.bin": b"previous room"}
def test_bad_index_and_trailing_partial_frame(scanner, dumps, tmp_path):
data = bytearray(dumps["one"].read_bytes())
nv = struct.unpack_from("<I", data, 44)[0]
struct.pack_into("<I", data, 52 + nv * 12, nv)
bad = tmp_path / "bad-index.fprs"
bad.write_bytes(data)
assert "index" in run(scanner, bad, tmp_path / "out", success=False)["error"]
bad.write_bytes(dumps["one"].read_bytes() + b"\0")
assert "truncated" in run(scanner, bad, tmp_path / "out", success=False)["error"]
@pytest.mark.parametrize("args", [("--voxel", "0"), ("--voxel", ".01"), ("--grazing", "nan"),
("--universe", "18446744073709551616"), ("--surprise", "1")])
def test_invalid_options_are_json_errors(scanner, dumps, tmp_path, args):
run(scanner, dumps["one"], tmp_path / "out", *args, success=False)
def test_coarser_voxels_and_repeatable_geometry(scanner, dumps, tmp_path):
first, second = tmp_path / "first", tmp_path / "second"
a = run(scanner, dumps["box"], first, "--voxel", ".08")
b = run(scanner, dumps["box"], second, "--voxel", ".08")
assert a["walls"] == b["walls"] == 4
assert (first / "room-mesh.bin").read_bytes() == (second / "room-mesh.bin").read_bytes()
assert room(first)["room"] != room(second)["room"] # fresh OS-random anchors on every scan
mesh(first)
@pytest.mark.parametrize("offset", [12, 16, 28, 52])
def test_nonfinite_frame_is_skipped_before_and_after_valid_frame(scanner, dumps, tmp_path, offset):
data = dumps["one"].read_bytes()
bad = bytearray(data[8:])
# High word of timestamp, pose position/quaternion, or a vertex; body offsets exclude FPRS header.
struct.pack_into("<I", bad, offset - 8, 0x7fffffff)
dump = tmp_path / "tracking-start.fprs"
dump.write_bytes(data[:8] + bad + data[8:] + bad)
report = run(scanner, dump, tmp_path / "out")
assert report["frames"] == 3 and report["skipped_frames"] == 2
assert "non-finite or invalid frames skipped" in report["warnings"]
assert len(outline(room(tmp_path / "out"))) >= 3
def test_far_vertices_are_dropped_per_vertex(scanner, dumps, tmp_path):
data = bytearray(dumps["one"].read_bytes())
# A single inpainted vertex must remove every incident triangle, without losing its whole frame.
struct.pack_into("<3f", data, 52, 0, 0, 9)
dump = tmp_path / "far.fprs"
dump.write_bytes(data)
report = run(scanner, dump, tmp_path / "out")
assert report["skipped_frames"] == 0 and report["far_vertices"] >= 1
assert report["rejected_triangles"] >= 1
@pytest.fixture(scope="module")
def roomview_dump(tmp_path_factory):
path = tmp_path_factory.mktemp("roomview-fan") / "synthetic.fprs"
synth.render(path)
return path
def frames(path):
with path.open("rb") as stream:
assert stream.read(8) == b"FPRS\x01\0\0\0"
while header := stream.read(44):
timestamp, *values = struct.unpack("<d7fII", header)
pose, nv, ni = values[:7], values[7], values[8]
vertices = list(struct.iter_unpack("<3f", stream.read(nv * 12)))
indices = struct.unpack(f"<{ni}I", stream.read(ni * 4))
yield timestamp, pose, vertices, indices
def test_roomview_renderer_grid_camera_axes_drift_and_inpainting(roomview_dump):
heights, far = [], 0
for i, (timestamp, pose, vertices, indices) in enumerate(frames(roomview_dump)):
assert abs(timestamp - i / 3) < 1e-8
assert len(vertices) == 132 * 128 and len(indices) == 99822
assert indices[:6] == (0, 132, 1, 1, 132, 133)
assert vertices[0][0] < 0 and vertices[0][1] < 0 and vertices[0][2] > 0
assert vertices[-1][0] > 0 and vertices[-1][1] > 0 and vertices[-1][2] > 0
if i < 2:
assert not all(map(math.isfinite, pose))
else:
assert all(map(math.isfinite, pose))
heights.append(pose[1])
far += sum(sum(c * c for c in vertex) > 16 for vertex in vertices)
assert .89 <= min(heights) <= .92 and 1.68 <= max(heights) <= 1.71
assert far > 1000
def test_depth_smoothing_reduces_local_noise_without_changing_grid():
depth = [1 + ((x * 31 + y * 17) % 7 - 3) * .04 for y in range(128) for x in range(132)]
filtered = synth.smooth_depths(depth, 132, 128)
assert len(filtered) == len(depth)
assert sum((v - 1) ** 2 for v in filtered) < sum((v - 1) ** 2 for v in depth) * .1
assert synth.smooth_depths([2.0] * (132 * 128), 132, 128) == [2.0] * (132 * 128)
def test_roomview_style_scan_retains_room_and_furniture(scanner, roomview_dump, tmp_path):
report = run(scanner, roomview_dump, tmp_path)
assert report["frames"] == 30 and report["skipped_frames"] == 2
assert report["far_vertices"] > 1000
assert report["detected_walls"] == 4
assert abs(report["floor_y"]) < .05 and abs(report["ceiling_y"] - 2.6) < .05
records = room(tmp_path, "room-raw.txt")
assert_walls(outline(records), synth.BOX_OUTLINE)
assert any(row[1] == "TABLE" and abs(float(row[4]) - .75) < .05 for row in records["volume"])
assert any(row[1] == "COUCH" for row in records["volume"])
mesh(tmp_path, "room-mesh-raw.bin")
mesh(tmp_path)
assert report["heads"] == 28 # the tracked frames' head path, for the walked-volume pass
assert len(json.loads((tmp_path / "scan.json").read_text())["heads"]) == 28
def test_depth_discontinuity_is_rejected_below_absolute_edge_limit(scanner, dumps, tmp_path):
data = dumps["one"].read_bytes()
vertices = (0, 0, 1, .1, 0, 1, 0, .1, 1.5)
frame = struct.pack("<d7fII", 0, 0, 1, 0, 0, 0, 0, 1, 3, 3)
frame += struct.pack("<9f3I", *vertices, 0, 1, 2)
path = tmp_path / "discontinuity.fprs"
path.write_bytes(data[:8] + frame + data[8:])
report = run(scanner, path, tmp_path / "out")
assert report["discontinuity_triangles"] >= 1
def test_small_ceiling_patch_does_not_override_default(scanner, dumps, tmp_path):
# A lone down-facing patch at shelf height (1.8 m, below a 2 m ceiling) is no ceiling. A patch at ceiling height
# is accepted on purpose: the owner's real ceiling (2026-10-16) was only ~0.1 m^2 of fused cells after looking
# up, less evidence than a 0.7 m patch seen ten times, so area or a share of the floor can't tell them apart.
data = dumps["down"].read_bytes()
width = 15
vertices = [(x * .05 - .35, .3, z * .05 - .35) for z in range(width) for x in range(width)]
indices = []
for z in range(width - 1):
for x in range(width - 1):
a = z * width + x
indices.extend((a, a + width, a + 1, a + 1, a + width, a + width + 1))
frame = struct.pack("<d7fII", 0, 0, 1.5, 0, 0, 0, 0, 1, len(vertices), len(indices))
frame += b"".join(struct.pack("<3f", *v) for v in vertices)
frame += struct.pack(f"<{len(indices)}I", *indices)
path = tmp_path / "weak-ceiling.fprs"
path.write_bytes(data + frame * 10)
report = run(scanner, path, tmp_path / "out", "--ceiling-default", "2.7")
assert "ceiling not seen" in report["warnings"]
assert abs(report["ceiling_y"] - report["floor_y"] - 2.7) < .001
# --- Refinement + label rules (docs/mr/scan.md "Refinement") ---------------------------------------------------------
def refine_only(scanner, directory, *args, success=True):
proc = subprocess.run([str(scanner), "--refine-only", str(directory), "--json", *args],
text=True, capture_output=True, timeout=15)
assert proc.returncode == (0 if success else 2), proc.stdout + proc.stderr
report = json.loads(proc.stdout)
assert report["ok"] is success
return report
def centroids(directory, name="room-mesh.bin"):
vertices, indices = mesh(directory, name)
return [[sum(vertices[indices[i + k]][a] for k in range(3)) / 3 for a in range(3)]
for i in range(0, len(indices), 3)]
def in_box(c, box, pad=.06):
(cx, bottom, cz), (w, h, d) = box
return abs(c[0] - cx) <= w / 2 + pad and bottom - pad <= c[1] <= bottom + h + pad and abs(c[2] - cz) <= d / 2 + pad
def masked(text, mesh_name=None):
text = re.sub(r"(?m)^time \d+", "time T", re.sub(r"[0-9a-f]{32}", "U", text))
return text.replace(f" {mesh_name} ", " MESH ") if mesh_name else text
@pytest.fixture(scope="module")
def clutter_dump(tmp_path_factory):
path = tmp_path_factory.mktemp("clutter") / "clutter.fprs"
synth.render(path, profile="legacy", extra=[synth.TUBE, synth.GHOST])
return path
@pytest.fixture(scope="module")
def box_scan(scanner, dumps, tmp_path_factory):
out = tmp_path_factory.mktemp("box-scan") / "room"
return out, run(scanner, dumps["box"], out)
def test_floating_tube_and_walked_through_ghost_are_removed(scanner, clutter_dump, tmp_path):
report = run(scanner, clutter_dump, tmp_path / "default")["refine"]
raw, refined = centroids(tmp_path / "default", "room-mesh-raw.bin"), centroids(tmp_path / "default")
for box in (synth.TUBE, synth.GHOST):
assert sum(in_box(c, box) for c in raw) > 100 # the scanner meshed both
assert not any(in_box(c, box) for c in refined)
assert report["thin_pieces"] > 100 and report["walked"] > 100
# Each piece goes for its own reason: without the walked volume the ghost stays, without tubes the pole stays.
run(scanner, clutter_dump, tmp_path / "unwalked", "--walked", "0")
assert sum(in_box(c, synth.GHOST) for c in centroids(tmp_path / "unwalked")) > 100
run(scanner, clutter_dump, tmp_path / "untubed", "--tubes", "0")
assert sum(in_box(c, synth.TUBE) for c in centroids(tmp_path / "untubed")) > 100
# Real furniture survives: the table top is flattened onto its box, not removed.
assert any(in_box(c, synth.TABLE) and abs(c[1] - .75) < .06 for c in refined)
def test_panels_wind_into_the_room_on_the_reestimated_planes(box_scan):
out, report = box_scan
refine = report["refine"]
assert refine["panel_triangles"] == 12 # four seen walls + floor and ceiling (two ears each)
vertices, indices = mesh(out)
records = room(out)
ceiling = float(records["ceiling"][0][1])
assert abs(ceiling - refine["ceiling_y"]) < 1e-4 and abs(ceiling - 2.6) < .05
assert all(float(w[6]) == ceiling for w in records["wall"]) # wall tops follow the re-estimated ceiling
panels = len(indices) // 3 - refine["panel_triangles"]
for t in range(panels, len(indices) // 3):
a, b, c = (vertices[indices[3 * t + k]] for k in range(3))
u, v = [b[k] - a[k] for k in range(3)], [c[k] - a[k] for k in range(3)]
normal = (u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0])
centroid = [(a[k] + b[k] + c[k]) / 3 for k in range(3)]
assert sum(n * (o - p) for n, o, p in zip(normal, (0, 1.3, 0), centroid, strict=True)) > 0
ys = {round(p[1], 4) for p in (a, b, c)}
assert ys <= {round(refine["floor_y"], 4), round(ceiling, 4)}
def test_refine_only_reproduces_the_scan_and_refine_zero_is_the_old_output(scanner, dumps, box_scan, tmp_path):
out, report = box_scan
copy = tmp_path / "copy"
shutil.copytree(out, copy)
for name in ("room.txt", "room-mesh.bin"):
(copy / name).unlink()
again = refine_only(scanner, copy)
assert again["refine"] == report["refine"]
for name in ("room.txt", "room-mesh.bin", "scan.json", "room-raw.txt", "room-mesh-raw.bin"):
assert (copy / name).read_bytes() == (out / name).read_bytes(), name
# --refine 0 writes what the scanner wrote before refinement existed: its raw output, byte for byte.
plain = tmp_path / "plain"
run(scanner, dumps["box"], plain, "--refine", "0")
assert (plain / "room-mesh.bin").read_bytes() == (out / "room-mesh-raw.bin").read_bytes()
assert masked((plain / "room.txt").read_text(), "room-mesh.bin") == \
masked((out / "room-raw.txt").read_text(), "room-mesh-raw.bin")
# ... and switching refinement back on later gives the default output again (same anchors as its own raw room).
refine_only(scanner, plain)
assert (plain / "room-mesh.bin").read_bytes() == (out / "room-mesh.bin").read_bytes()
assert masked((plain / "room.txt").read_text()) == masked((out / "room.txt").read_text())
def test_scan_json_holds_settings_report_and_heads(box_scan):
out, report = box_scan
scan = json.loads((out / "scan.json").read_text())
assert scan["version"] == 1 and len(scan["heads"]) == report["heads"] > 0
assert all(len(h) == 3 for h in scan["heads"])
assert scan["refine"] == {"refine": 1, "walls": "panels", "band": .35, "facing_deg": 60, "band_in": .12,
"floor_in": .08, "ceil_in": .25, "top_strip": .5, "near": .03, "clip": .05,
"clip_open": .1, "island": .2, "smooth": 8, "tops": .1, "protect": 0, "tubes": 3,
"tube_thick": .15, "walked": .4, "label_rules": 1, "erase": []}
summary = dict(report["refine"])
assert summary.pop("output_triangles") == len(mesh(out)[1]) // 3 and summary.pop("output_volumes") == 2
assert summary == scan["report"]
assert scan["report"]["kept"] + scan["report"]["panel_triangles"] == len(mesh(out)[1]) // 3
assert sorted((label["from"], label["to"]) for label in scan["labels"]) == [("COUCH", "TABLE"), ("TABLE", "TABLE")]
assert scan["suggestions"] == []
def test_head_path_is_decimated_to_two_thousand_points(scanner, dumps, tmp_path):
data = dumps["one"].read_bytes()
empty = b"".join(struct.pack("<d7fII", i, i * .001, 1.6, 0, 0, 0, 0, 1, 0, 0) for i in range(2500))
dump = tmp_path / "long.fprs"
dump.write_bytes(data + empty)
report = run(scanner, dump, tmp_path / "out", "--min-views", "1") # one frame with depth, 2500 without
heads = json.loads((tmp_path / "out" / "scan.json").read_text())["heads"]
assert 1000 <= report["heads"] == len(heads) <= 2000
assert heads[-1][0] > 2 # still spans the whole recording
def test_erase_spheres_remove_the_scan_inside_them(scanner, box_scan, tmp_path):
copy = tmp_path / "copy"
shutil.copytree(box_scan[0], copy)
report = refine_only(scanner, copy, "--erase", "-0.6,0.75,-0.25,0.3;1.5,1.0,-1.0,0.2")["refine"]
assert report["erased"] > 0
assert not any((c[0] + .6) ** 2 + (c[1] - .75) ** 2 + (c[2] + .25) ** 2 <= .09 for c in centroids(copy))
assert json.loads((copy / "scan.json").read_text())["refine"]["erase"] == [[-.6, .75, -.25, .3], [1.5, 1, -1, .2]]
ROOM_IDS = iter(f"{i:032x}" for i in range(1, 1000))
def volume(label, cx, cz, top, yaw, w, d, h):
return f"volume {next(ROOM_IDS)} {label} {cx} {cz} {top} {yaw} {w} {d} {h}\n"
def test_label_rules_on_an_older_room_folder(scanner, tmp_path):
"""Merged and nested boxes and boxes behind a wall go, shapes are relabelled, a door slab becomes a suggestion.
The folder has no raw files (an older scan): its room.txt + mesh are taken as the unrefined scan."""
walls = [(-2, -1.5, 2, -1.5), (2, -1.5, 2, 1.5), (2, 1.5, -2, 1.5), (-2, 1.5, -2, -1.5)]
plane = f"plane {next(ROOM_IDS)} DOOR_FRAME 0.0000 1.0000 1.4900 0.0000 0.0000 0.0000 1.0000 0.9000 2.0000\n"
text = (f"frameport-room 1\nuniverse 7\nsource scan\ntime 1\nroom {next(ROOM_IDS)}\nfloor {next(ROOM_IDS)} 0\n"
f"ceiling {next(ROOM_IDS)} 2.6\noutline 4 -2 -1.5 2 -1.5 2 1.5 -2 1.5\n"
+ "".join(f"wall {next(ROOM_IDS)} {a} {b} {c} {d} 0 2.6\n" for a, b, c, d in walls)
+ volume("TABLE", -1.2, -.5, .75, 0, 1.0, .6, .75)
+ volume("COUCH", .6, -.5, .5, 0, 1.6, .8, .5)
+ volume("OTHER", -.3, -.5, .8, 0, 3.0, 1.0, .8) # holds the table and the couch: merged
+ volume("BED", .6, .85, .55, 0, 2.0, 1.1, .55)
+ volume("OTHER", .6, .85, .45, 0, .5, .4, .45) # inside the bed: part of it
+ volume("OTHER", 1.85, -.8, 1.6, -90, 1.0, .06, .6) # a flat panel along the x = 2 wall: a screen
+ volume("SCREEN", -1.0, -1.45, 2.0, 0, .9, .1, 2.0) # a tall slab by the z = -1.5 wall: a door?
+ volume("STORAGE", 2.6, 0, 1.5, 0, .5, .5, 1.5) # behind a wall
+ plane)
vertices, indices = [], []
def quad(a, b, c, d):
base = len(vertices)
vertices.extend((a, b, c, d))
indices.extend((base, base + 1, base + 2, base, base + 2, base + 3))
for i in range(18): # the screen: a flat 0.9 x 0.5 m grid at x = 1.85
for j in range(10):
z, y = -1.25 + i * .05, 1.05 + j * .05
quad((1.85, y, z), (1.85, y, z + .05), (1.85, y + .05, z + .05), (1.85, y + .05, z))
for i in range(38): # some floor
for j in range(28):
x, z = -1.9 + i * .1, -1.4 + j * .1
quad((x, 0, z), (x, 0, z + .1), (x + .1, 0, z + .1), (x + .1, 0, z))
text += f"mesh {next(ROOM_IDS)} room-mesh.bin {len(vertices)} {len(indices)}\n"
mesh_bytes = (struct.pack("<4sIII", b"FPRM", 1, len(vertices), len(indices))
+ b"".join(struct.pack("<3f", *v) for v in vertices) + struct.pack(f"<{len(indices)}I", *indices))
folder = tmp_path / "room"
folder.mkdir()
(folder / "room.txt").write_text(text)
(folder / "room-mesh.bin").write_bytes(mesh_bytes)
report = refine_only(scanner, folder)
assert "no raw files; the current room was taken as the unrefined scan" in report["warnings"]
assert "no head path; walked volume skipped" in report["warnings"]
assert (folder / "room-mesh-raw.bin").read_bytes() == mesh_bytes
scan = json.loads((folder / "scan.json").read_text())
decisions = [(label["from"], label["to"], label["reason"]) for label in scan["labels"]]
assert decisions == [
("TABLE", "TABLE", "top 0.75 m"),
("COUCH", "COUCH", "seat-height top 0.50 m, 1.6 m long"),
("OTHER", None, "holds a table and a couch 1.8 m apart: several things merged"),
("BED", "BED", "2.2 m², 2.0 m long, top 0.55 m"),
("OTHER", None, "100% inside the bigger bed box: part of it"),
("OTHER", "SCREEN", "thin, raised, screen-shaped, flat (0 cm)"),
("SCREEN", None, "tall thin slab from the floor (2.00 m): a door or closet door?"),
("STORAGE", None, "centre 60 cm outside the room (behind a wall)"),
]
assert scan["suggestions"] == [{"kind": "door", "box": {"cx": -1, "cz": -1.45, "top_y": 2, "yaw_deg": 0,
"w": .9, "d": .1, "h": 2}, "wall": 0, "along": .25,
"reason": decisions[6][2]}]
raw = room(folder, "room-raw.txt")
assert raw["mesh"][0][1] == "room-mesh-raw.bin" and len(raw["volume"]) == 8
refined = room(folder)
assert [row[1] for row in refined["volume"]] == ["TABLE", "COUCH", "BED", "SCREEN"]
assert refined["universe"] == [["7"]] and refined["plane"] == [plane.split()[1:]] # unknown records stay
assert report["refine"]["removed_boxes"] == 4 and report["refine"]["relabelled"] == 1
assert report["refine"]["suggestions"] == 1
first = {name: (folder / name).read_bytes() for name in ("room.txt", "room-mesh.bin", "scan.json")}
refine_only(scanner, folder) # now from the raw files: the same result
assert first == {name: (folder / name).read_bytes() for name in first}
refine_only(scanner, folder, "--label-rules", "0")
assert [row[1] for row in room(folder)["volume"]] == [
"TABLE", "COUCH", "OTHER", "BED", "OTHER", "OTHER", "SCREEN", "STORAGE"]
@pytest.mark.parametrize("args", [("--walls", "shell"), ("--smooth", "1.5"), ("--erase", "1,2,3"), ("--refine", "2"),
("--band", "2"), ("--walked", ".01"), ("--erase", "0,0,0,-1")])
def test_invalid_refinement_options_are_json_errors(scanner, dumps, tmp_path, args):
run(scanner, dumps["one"], tmp_path / "out", *args, success=False)
def test_refine_only_needs_a_room(scanner, tmp_path):
report = refine_only(scanner, tmp_path, success=False)
assert "room-raw.txt" in report["error"]