mirror of
https://github.com/thunderheronight/splat-workshop.git
synced 2026-10-18 17:01:44 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd8cd8dbb3 | ||
|
|
da00372c5a | ||
|
|
a8a6aa5188 | ||
|
|
49124eacc8 | ||
|
|
4f339b2150 |
No files matched your search
@@ -0,0 +1,14 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
- name: Run the tests
|
||||
run: uv run --no-project --python 3.12 --with pytest --with numpy --with scipy --with pillow pytest -v
|
||||
@@ -3,3 +3,4 @@ __pycache__/
|
||||
.venv/
|
||||
.splat-workshop/
|
||||
*.part
|
||||
.pytest_cache/
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
All notable changes to Splat Workshop. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.2.2] – 2026-10-14
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tests hardened after an independent review in two rounds** (213 instead of 57): every deliberately broken piece of code from the review is now caught (mutation testing). New: the recording check, renaming recordings, the Frame's list, downloads (also a broken one) and copying scenes to the Frame (through a fake `ssh`), recovery after a power cut, what happens after a job (notification, tidying up), *Start over* from the list of cancelled runs, scenes being left alone while they are computed, checks before a conversion starts (fake `ffprobe` instead of switching the check off), splat orientation and size when putting rooms together, correction D against C on a turned tracking, the reduced Frame version. Synthetic rooms now have tracking noise, scale errors and turned splats. Even with broken code the tests can no longer open anything on the desktop or end the test run unnoticed.
|
||||
- Known limit, now written down as a test: walking into a room is only recognised up to 0.4 m from another recording's path.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Wrongly typed fields (e.g. `members: 5` when saving a group) were answered with an internal error or stored – now they get a clean 400.
|
||||
|
||||
## [0.2.1] – 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Automated tests** (57) that run on every push via GitHub Actions – no GPU, recordings or Frame needed: the server's protection against other websites and malformed requests, scenes, groups, conversions with cancel/start over/resume, tours, the four corrections on synthetic rooms with a known error, borders through doors, and the floor plan's orientation. See *Development* in the README.
|
||||
|
||||
### Fixed
|
||||
|
||||
- After cancelling, a conversion counted as finished as soon as the sleep inhibitor ended, while `splat.sh` was still putting the previous scene back – now the whole job has to end.
|
||||
|
||||
## [0.2.0] – 2026-10-14
|
||||
|
||||
The big one: **put the rooms of your home together into one walkable tour.**
|
||||
@@ -44,5 +65,7 @@ First release.
|
||||
- **Floor plans** (experimental) in two styles with a measuring tool.
|
||||
- Configurable video player; English and German interface.
|
||||
|
||||
[0.2.2]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://github.com/thunderheronight/splat-workshop/releases/tag/v0.1.0
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
**Eine grafische Oberfläche für die Rekonstruktions-Pipeline von Arcturus Vision: Aufnahme der Arcturus Vision Camera an der Steam Frame auswählen, prüfen, umwandeln lassen und das Ergebnis direkt in die Gaussian-XR-Galerie auf der Brille bringen. Mit Weboberfläche, Tray-Symbol und Aufnahmeprüfung.**
|
||||
|
||||
  [](https://github.com/thunderheronight/splat-workshop/releases) [](https://github.com/thunderheronight/splat-workshop/actions/workflows/tests.yml)
|
||||
|
||||
> **Kein Root, kein Jailbreak.** Auf der Frame nutzt die Splat-Werkstatt nur den offiziellen *Entwicklermodus* aus den Steam-Einstellungen und SSH als normaler Benutzer – auf der Brille wird nichts installiert, ihr System bleibt unangetastet. Auch am PC läuft sie als dein Benutzer (nur die Voraussetzungen wie ffmpeg kommen aus der Paketverwaltung).
|
||||
|
||||
[English](README.md)
|
||||
|
||||

|
||||
@@ -26,6 +30,9 @@ Arcturus Vision stellt die Rekonstruktions-Skripte und einen Spirula-Studio-Fork
|
||||
- 🧹 **Speicherplatz, Aufräumen, Löschen** pro Szene und eine Liste **abgebrochener Läufe** zum Fortsetzen oder Entfernen.
|
||||
- 🛡️ **Sicherer**: *Neu rechnen* behält die alte Szene, bis die neue fertig ist; der PC geht während einer Umwandlung nicht mehr in den Standby.
|
||||
|
||||
<img src="docs/groups.png" width="800" alt="Eine Gruppe „Example home“ mit sechs Räumen: der daraus zusammengesetzte Rundgang, sein Grundriss als Vorschau, wie gut die Räume aneinanderpassen, darunter die Räume">
|
||||
<br><sub>Beispieldaten (englische Oberfläche) – Raumnamen und Vorschaubilder sind Platzhalter, die Rundgang-Vorschau ist die synthetische Beispielwohnung.</sub>
|
||||
|
||||
Alle Änderungen: [CHANGELOG.md](CHANGELOG.md) (englisch)
|
||||
|
||||
## Funktionen
|
||||
@@ -36,6 +43,8 @@ Alle Änderungen: [CHANGELOG.md](CHANGELOG.md) (englisch)
|
||||
- **Abbrechen & Fortsetzen** ab dem letzten Zwischenstand.
|
||||
- **Aufnahmeprüfung ohne KI**: reine Messwerte je Sekunde, farbige Zeitleiste und konkrete Hinweise („0:45–0:59 wenig Struktur“).
|
||||
- **Gruppen und Rundgänge**: Szenen gruppieren (z. B. alle Räume der Wohnung) und zu einer Szene zusammensetzen – eine volle Fassung für Gaussian XR am PC und eine kleinere für die Frame. Aufnahmen aus einem Durchgang teilen das Tracking-Koordinatensystem der Frame; die Werkstatt richtet die Räume aneinander aus (Korrekturen A–D zur Wahl), legt die Grenzen zwischen den Aufnahmen in die Türen und zeigt, wie gut die Räume aneinanderpassen. Vor dem Start schätzt sie den Speicherbedarf.
|
||||
<br><img src="docs/combine.png" width="360" alt="Dialog „Zusammensetzen“: Korrekturen A bis D, Grenzen zwischen den Aufnahmen, Größe der Frame-Fassung, volle Fassung, Speicherschätzung">
|
||||
<br><sub>Beispieldaten (englische Oberfläche) – Raumnamen und Vorschaubilder sind Platzhalter.</sub>
|
||||
- **Grundrisse** *(experimentell)* einzelner Szenen oder einer ganzen Gruppe – Aufnahmen aus einem Durchgang fügen sich von selbst zum Grundriss der ganzen Wohnung. Zwei Stile: *Hübsch* (Draufsicht in Farbe, Wände, gelaufener Weg) und *Klassisch* (leuchtende Punktdichte), dazu ein einfaches Messwerkzeug (Punkte anklicken, Abstände ablesen – etwa ±5 cm genau).
|
||||
- **Fertige Szenen**: später auf die Frame übertragen, erneut übertragen, umbenennen (Ordner und Namensschild ziehen mit), löschen (in den Papierkorb), Ordner oder Spirula Studio öffnen.
|
||||
- **Abgebrochene Läufe** werden mit ihrem Stand angezeigt: fortsetzen, neu starten oder löschen.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
**A graphical interface for the Arcturus Vision reconstruction pipeline: pick a recording from the Arcturus Vision Camera on your Steam Frame, check it, run the conversion and get the result straight into the Gaussian XR gallery on the headset. With a web interface, a tray icon and a recording check.**
|
||||
|
||||
  [](https://github.com/thunderheronight/splat-workshop/releases) [](https://github.com/thunderheronight/splat-workshop/actions/workflows/tests.yml)
|
||||
|
||||
> **No root, no jailbreak.** On the Frame, Splat Workshop only uses the official *Developer Mode* from the Steam settings and SSH as the normal user – nothing is installed on the headset and its system stays untouched. On the PC it runs as your user, too (only the prerequisites like ffmpeg come from your package manager).
|
||||
|
||||
[Deutsch](README.de.md)
|
||||
|
||||

|
||||
@@ -28,6 +32,9 @@ Arcturus Vision publishes the reconstruction scripts and a Spirula Studio fork,
|
||||
- 🧹 **Disk usage, clean-up, delete** per scene, and a list of **cancelled runs** to resume or remove.
|
||||
- 🛡️ **Safer**: *Start over* keeps the old scene until the new one is done; the PC no longer goes to sleep mid-conversion.
|
||||
|
||||
<img src="docs/groups.png" width="800" alt="A group “Example home” with six rooms: the tour put together from them, its floor plan as preview, how well the rooms meet, and the rooms below">
|
||||
<br><sub>Example data – room names and preview images are placeholders; the tour preview is the synthetic example home.</sub>
|
||||
|
||||
All changes: [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Features
|
||||
@@ -38,6 +45,8 @@ All changes: [CHANGELOG.md](CHANGELOG.md)
|
||||
- **Cancel & resume**: a cancelled conversion continues from its last saved state.
|
||||
- **Recording check** without AI: plain per-second measurements with a colour-coded timeline and concrete hints (“0:45–0:59 little structure”). See [What the check measures](#what-the-check-measures).
|
||||
- **Groups and tours**: group scenes (e.g. all rooms of your home) and put them together into one scene – a full version for Gaussian XR on the PC and a smaller one for the Frame. Recordings from one session share the Frame's tracking frame; Splat Workshop fits the rooms to each other (corrections A–D to choose), lets the borders between recordings run through doors and shows how well the rooms meet. Before starting it estimates the memory needed.
|
||||
<br><img src="docs/combine.png" width="360" alt="Dialog “Put together”: corrections A to D, borders between the recordings, size of the Frame version, full version, memory estimate">
|
||||
<br><sub>Example data – room names and preview images are placeholders.</sub>
|
||||
- **Floor plans** *(experimental)* of single scenes or a whole group – recordings from one session line up by themselves into a plan of your whole home. Two styles: *Pretty* (colour top view, walls, the path you walked) and *Classic* (glowing point density), plus a simple measuring tool (click points, get distances – about ±5 cm).
|
||||
<br><img src="docs/floorplan.png" width="640" alt="Combined floor plan of an example home in the classic style, with the measuring tool">
|
||||
<br><sub>Example home – synthetic data, drawn with the same renderer. Combining rooms relies on the Frame keeping its tracking frame between recordings; how well that holds for a whole home is still being tested.</sub>
|
||||
@@ -156,6 +165,18 @@ tours: [paths] runs/<group>/ (scene.sog = Frame version, full/ = ful
|
||||
internal state: next to runs/ in .splat-workshop/ (logs, thumbnails, check results)
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Automated tests run on every push ([GitHub Actions](https://github.com/thunderheronight/splat-workshop/actions/workflows/tests.yml)). Run them yourself with:
|
||||
|
||||
```bash
|
||||
uv run --no-project --with pytest --with numpy --with scipy --with pillow pytest
|
||||
```
|
||||
|
||||
They need no GPU, no recordings and no Steam Frame: the Arcturus pipeline, Spirula, splat-transform, ffprobe, the desktop trash and notifications are replaced by small fakes, the Frame is a folder reached through a fake `ssh`/`scp`, and putting rooms together is tested on synthetic rooms with a known error – turned, shifted and scaled reconstructions, tracking noise, drift and turn (`tests/synth.py`).
|
||||
|
||||
Covered: who may talk to the server (other websites, LAN mode, malformed or wrongly typed requests, paths outside the runs folder), the recording check, renaming recordings and scenes, clean-up, deleting, cancelled runs, a scene being left alone while it is computed, recovery after a power cut, groups, conversions with cancel/start over/resume (the real `splat.sh`), the Frame's list, downloads and copying scenes over (the real `to-frame.sh`), notifications and tidying up after a job, tours (the real `combine.sh`), the four corrections, splat orientation and size, borders through doors, and that the floor plan is not mirrored. The tests were reviewed independently with mutation testing (deliberately broken code must make them fail). Not covered: the real pipeline, Spirula, the real Frame, the tray icon, the browser interface and the server stopping itself when idle – those are tested by hand.
|
||||
|
||||
## Uninstall
|
||||
|
||||
`./install.sh --uninstall` removes the menu entry, icon and autostart. Your config and scenes stay.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 267 KiB |
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
+45
-27
@@ -29,7 +29,7 @@ from pathlib import Path
|
||||
from . import check, config
|
||||
from .i18n import T, detect
|
||||
|
||||
VERSION = "0.2.0"
|
||||
VERSION = "0.2.2"
|
||||
CFG = config.load()
|
||||
ROOT = config.ROOT
|
||||
WEB = ROOT / "web"
|
||||
@@ -134,10 +134,22 @@ def save_state(st: dict) -> None:
|
||||
|
||||
|
||||
def pid_alive(pid: int) -> bool:
|
||||
"""Is the job started as `pid` still running? The job is its whole process group: the first process
|
||||
(systemd-inhibit, if the PC is kept awake) can end before splat.sh has finished cleaning up."""
|
||||
if proc is not None and proc.pid == pid:
|
||||
proc.poll() # reap it, so a finished first process does not count
|
||||
try:
|
||||
cmd = Path(f"/proc/{pid}/cmdline").read_bytes().decode(errors="replace")
|
||||
return "splat.sh" in cmd or "combine.sh" in cmd
|
||||
if cmd:
|
||||
return "splat.sh" in cmd or "combine.sh" in cmd
|
||||
except OSError:
|
||||
pass
|
||||
if not pid:
|
||||
return False
|
||||
try:
|
||||
os.killpg(pid, 0) # the first process is gone, are others of the job left?
|
||||
return True
|
||||
except (ProcessLookupError, PermissionError):
|
||||
return False
|
||||
|
||||
|
||||
@@ -503,7 +515,7 @@ def job_status(tail=True) -> dict | None:
|
||||
return None
|
||||
out = Path(st["out"])
|
||||
text = out.read_text(errors="replace") if out.exists() else ""
|
||||
running = (proc is not None and proc.poll() is None) or (proc is None and pid_alive(st["pid"]))
|
||||
running = pid_alive(st["pid"])
|
||||
parsed = parse_output(text)
|
||||
res = {**st, **parsed, "running": running}
|
||||
if tail:
|
||||
@@ -613,33 +625,38 @@ def activity() -> dict:
|
||||
"checking": check_busy(), "uploading": any(u["proc"].poll() is None for u in uploads.values())}
|
||||
|
||||
|
||||
def watch_once(was_running):
|
||||
"""One look at the jobs: notification and tidying up when a run has ended. Returns whether one runs."""
|
||||
if proc is not None:
|
||||
proc.poll() # reap the zombie
|
||||
js = job_status(tail=False)
|
||||
running = bool(js and js["running"])
|
||||
if was_running and not running and js:
|
||||
mins = round((time.time() - js["started"]) / 60)
|
||||
if js["result"] == "ok":
|
||||
if js["info"].get("folder"):
|
||||
write_meta(js["slug"], frame_folder=js["info"]["folder"])
|
||||
with meta_lock: # the replaced scene's settings are not needed any more
|
||||
meta = read_meta(js["slug"])
|
||||
if meta.pop("previous", None) is not None:
|
||||
write_json(meta_file(js["slug"]), meta)
|
||||
notify(t("notify.done.title"),
|
||||
t("notify.done.frame" if js["upload"] else "notify.done.local", name=js["name"], mins=mins))
|
||||
elif js["result"] == "failed":
|
||||
notify(t("notify.failed.title"), t("notify.failed", name=js["name"]))
|
||||
if not running:
|
||||
restore_previous()
|
||||
for slug in list(uploads):
|
||||
upload_status(slug) # notify even without an open page
|
||||
return running
|
||||
|
||||
|
||||
def watcher():
|
||||
"""Notification when a run ends; stop the server after a longer idle time."""
|
||||
"""Every few seconds: notifications and tidying up; stop the server after a longer idle time."""
|
||||
was_running = None
|
||||
while True:
|
||||
time.sleep(3)
|
||||
if proc is not None:
|
||||
proc.poll() # reap the zombie
|
||||
js = job_status(tail=False)
|
||||
running = bool(js and js["running"])
|
||||
if was_running and not running and js:
|
||||
mins = round((time.time() - js["started"]) / 60)
|
||||
if js["result"] == "ok":
|
||||
if js["info"].get("folder"):
|
||||
write_meta(js["slug"], frame_folder=js["info"]["folder"])
|
||||
with meta_lock: # the replaced scene's settings are not needed any more
|
||||
meta = read_meta(js["slug"])
|
||||
if meta.pop("previous", None) is not None:
|
||||
write_json(meta_file(js["slug"]), meta)
|
||||
notify(t("notify.done.title"),
|
||||
t("notify.done.frame" if js["upload"] else "notify.done.local", name=js["name"], mins=mins))
|
||||
elif js["result"] == "failed":
|
||||
notify(t("notify.failed.title"), t("notify.failed", name=js["name"]))
|
||||
was_running = running
|
||||
if not running:
|
||||
restore_previous()
|
||||
for slug in list(uploads):
|
||||
upload_status(slug) # notify even without an open page
|
||||
was_running = running = watch_once(was_running)
|
||||
busy = (running or (download and not download.get("finished")) or check_busy()
|
||||
or any(u["proc"].poll() is None for u in uploads.values()))
|
||||
if idle_exit and not busy and time.time() - last_request > CFG.idle_minutes * 60:
|
||||
@@ -1502,7 +1519,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
p = None
|
||||
# Only flat objects of simple values; text fields are text
|
||||
if not isinstance(p, dict) or any(not isinstance(v, (str, int, float, bool, type(None))) for v in p.values()) \
|
||||
or any(not isinstance(p.get(k, ""), str) for k in ("name", "new", "slug", "path", "what", "quality", "existing")):
|
||||
or any(not isinstance(p.get(k, ""), str) for k in ("name", "new", "slug", "path", "what", "quality", "existing", "members", "key",
|
||||
"align", "partition")):
|
||||
return self.send(400, {"error": err("err.bad_request")})
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
e = None
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Test setup: a temporary home for Splat Workshop with fake tools, and the server on a free port.
|
||||
|
||||
The real Arcturus pipeline, Spirula, splat-transform and the desktop trash are replaced by small fakes
|
||||
(written below), so the tests run anywhere – no GPU, no recordings, no Steam Frame. The server reads
|
||||
its configuration when it is imported, so everything is set up before that, once per test session.
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path[:0] = [str(ROOT), str(Path(__file__).parent)]
|
||||
|
||||
BASE = Path(tempfile.mkdtemp(prefix="splat-workshop-tests-"))
|
||||
RUNS, REC, STATE, BIN, TRASH, PIPE, FRAME_HOME = (BASE / n for n in (
|
||||
"runs", "recordings", "state", "bin", "trash", "pipeline", "frame"))
|
||||
for d in (RUNS, REC, STATE, BIN, TRASH, PIPE, FRAME_HOME):
|
||||
d.mkdir()
|
||||
TOOL_LOG = BASE / "tools.log" # what the fake tools were called with, one JSON line each
|
||||
|
||||
|
||||
def script(path: Path, text: str):
|
||||
path.write_text(text)
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
# Fake pipeline: behaves like av1_to_3dgs.py as far as Splat Workshop can see (progress lines, result files).
|
||||
# FAKE_PIPELINE=ok (default) | fail | slow (waits for 60 s, to be cancelled)
|
||||
script(PIPE / "av1_to_3dgs.py", r'''
|
||||
import base64, json, os, sys, time
|
||||
from pathlib import Path
|
||||
out = Path(sys.argv[2])
|
||||
mode = os.environ.get("FAKE_PIPELINE", "ok")
|
||||
# like the real pipeline: an unfinished training blocks --resume ("Incomplete training retained")
|
||||
run_json = out / "spirula" / "run.json"
|
||||
if "--resume" in sys.argv and run_json.exists() and json.loads(run_json.read_text()).get("status") != "complete":
|
||||
sys.exit("error: Incomplete training retained; use a new output directory to restart")
|
||||
# like the real one: an existing output folder is only continued with --resume, and only once the camera
|
||||
# positions are complete ("Incomplete native SfM retained" / "already exists without a matching manifest")
|
||||
dataset = out / "dataset" / "spirula-dataset.json"
|
||||
if out.exists() and any(out.iterdir()):
|
||||
if "--resume" not in sys.argv:
|
||||
sys.exit("error: Spirula SfM output already exists without a matching resume manifest")
|
||||
if not dataset.exists() or json.loads(dataset.read_text()).get("status") != "complete":
|
||||
sys.exit("error: Incomplete native SfM retained for diagnosis; use a fresh output directory")
|
||||
(out / "dataset").mkdir(parents=True, exist_ok=True)
|
||||
print("Extracting 20 images from 2 video streams", flush=True)
|
||||
(out / "dataset" / "spirula-dataset.json").write_text(json.dumps({"status": "complete"}))
|
||||
print("Running native Spirula SfM", flush=True)
|
||||
print("Aligned 20 cameras using orientation anchor cam0/0000.jpg: RMS 0.0110 m, median 0.0090 m.", flush=True)
|
||||
if mode == "fail":
|
||||
sys.exit("fake pipeline: failed on purpose")
|
||||
for step in (1000, 2000, 3000):
|
||||
print(f"step {step:6d}/3000 ({step // 30:3d}%) splats 1000 [elapsed 00:00:0{step // 1000}.000 | ETA 00:00:01.000] rgb_loss=0.1 ssim=0.8 psnr=28.5", flush=True)
|
||||
if mode == "slow":
|
||||
time.sleep(20)
|
||||
(out / "spirula").mkdir(exist_ok=True)
|
||||
(out / "spirula" / "scene.ply").write_bytes(b"ply\n")
|
||||
run_json.write_text(json.dumps({"status": "complete"}))
|
||||
print("spirula: 1,000 Gaussians in " + str(out / "spirula" / "scene.ply"), flush=True)
|
||||
(out / "scene.sog").write_bytes(b"fake sog")
|
||||
(out / "scene.xrSceneTransform.json").write_text(json.dumps({"transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}))
|
||||
png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
|
||||
(out / (out.name.lower() + ".gallery.png")).write_bytes(png)
|
||||
print("Gaussian XR scene transform: " + str(out / "scene.xrSceneTransform.json"), flush=True)
|
||||
''')
|
||||
script(BIN / "spirula", "#!/bin/sh\nexit 0\n")
|
||||
# splat-transform: copies the input (a .ply stays a .ply), writes a small file for .sog
|
||||
script(BIN / "splat-transform", f'''#!{sys.executable}
|
||||
import json, shutil, sys
|
||||
with open("{TOOL_LOG}", "a") as log:
|
||||
log.write(json.dumps(["splat-transform", *sys.argv[1:]]) + "\\n")
|
||||
args = [a for a in sys.argv[1:] if a not in ("-w", "--overwrite")]
|
||||
if "--decimate" in args:
|
||||
i = args.index("--decimate"); del args[i:i + 2]
|
||||
src, dst = args[0], args[-1]
|
||||
if dst.endswith(".ply"):
|
||||
shutil.copy(src, dst)
|
||||
else:
|
||||
open(dst, "wb").write(b"fake sog")
|
||||
print("done")
|
||||
''')
|
||||
# gio trash: moves into a trash folder of the test (numbered if a name repeats)
|
||||
script(BIN / "gio", f'''#!/bin/sh
|
||||
[ "$1" = trash ] || exit 1
|
||||
shift; [ "$1" = -- ] && shift
|
||||
exec mv --backup=numbered -t "{TRASH}" -- "$@"
|
||||
''')
|
||||
|
||||
# Nothing may open on the desktop, even if the code under test is broken: file dialogs, xdg-open (folder,
|
||||
# video player) only leave a note in the tool log
|
||||
for tool in ("kdialog", "zenity", "xdg-open"):
|
||||
script(BIN / tool, f'''#!{sys.executable}
|
||||
import json, sys
|
||||
with open("{TOOL_LOG}", "a") as log:
|
||||
log.write(json.dumps(["{tool}", *sys.argv[1:]]) + "\\n")
|
||||
''')
|
||||
# notify-send: no desktop notifications from the tests, only a note in the tool log
|
||||
script(BIN / "notify-send", f'''#!{sys.executable}
|
||||
import json, sys
|
||||
with open("{TOOL_LOG}", "a") as log:
|
||||
log.write(json.dumps(["notify-send", *sys.argv[1:]]) + "\\n")
|
||||
''')
|
||||
# ffprobe: a good Arcturus recording (two video streams, gpmd, 60 s) unless the file name says otherwise:
|
||||
# "mono" one video stream, "nogpmd" no metadata stream, "short" 5 s, "brief" 15 s
|
||||
script(BIN / "ffprobe", f'''#!{sys.executable}
|
||||
import json, sys
|
||||
name = sys.argv[-1]
|
||||
video = {{"codec_type": "video", "codec_tag_string": "hvc1", "width": 2464, "height": 2464, "avg_frame_rate": "30/1"}}
|
||||
streams = [video] if "mono" in name else [video, dict(video)]
|
||||
if "nogpmd" not in name:
|
||||
streams.append({{"codec_type": "data", "codec_tag_string": "gpmd"}})
|
||||
dur = 5 if "short" in name else 15 if "brief" in name else 60
|
||||
print(json.dumps({{"streams": streams, "format": {{"duration": str(dur)}}}}))
|
||||
''')
|
||||
# ssh / scp: the Frame is a folder (its home directory); FAKE_FRAME_OFF=1 makes it unreachable
|
||||
script(BIN / "ssh", f'''#!{sys.executable}
|
||||
import os, subprocess, sys
|
||||
if os.environ.get("FAKE_FRAME_OFF"):
|
||||
sys.exit(255)
|
||||
args = sys.argv[1:]
|
||||
while args and args[0].startswith("-"):
|
||||
args = args[2:] if args[0] in ("-o", "-p", "-i") else args[1:]
|
||||
cmd = " ".join(args[1:])
|
||||
env = {{**os.environ, "HOME": "{FRAME_HOME}"}}
|
||||
if os.environ.get("FAKE_FRAME_CUT") and cmd.startswith("cat "): # the connection breaks halfway
|
||||
out = subprocess.run(["bash", "-c", cmd], cwd="{FRAME_HOME}", env=env, capture_output=True).stdout
|
||||
sys.stdout.buffer.write(out[:len(out) // 2])
|
||||
sys.exit(255)
|
||||
sys.exit(subprocess.run(["bash", "-c", cmd or "true"], cwd="{FRAME_HOME}", env=env).returncode)
|
||||
''')
|
||||
script(BIN / "scp", f'''#!{sys.executable}
|
||||
import os, shutil, sys
|
||||
from pathlib import Path
|
||||
if os.environ.get("FAKE_FRAME_OFF"):
|
||||
sys.exit(255)
|
||||
args = [a for a in sys.argv[1:] if a not in ("-q",)]
|
||||
def local(a):
|
||||
if ":" in a.split("/", 1)[0]:
|
||||
return Path("{FRAME_HOME}") / a.split(":", 1)[1]
|
||||
return Path(a)
|
||||
*src, dst = map(local, args)
|
||||
for s in src:
|
||||
shutil.copy(s, dst / s.name if dst.is_dir() else dst)
|
||||
''')
|
||||
|
||||
(BASE / "config.toml").write_text(f'''
|
||||
[paths]
|
||||
pipeline = "{PIPE}"
|
||||
spirula = "{BIN / 'spirula'}"
|
||||
splat_transform = "{BIN / 'splat-transform'}"
|
||||
recordings = "{REC}"
|
||||
runs = "{RUNS}"
|
||||
extra_recording_dirs = []
|
||||
[server]
|
||||
language = "en"
|
||||
[frame]
|
||||
host = ""
|
||||
''')
|
||||
os.environ.update(SPLAT_WORKSHOP_CONFIG=str(BASE / "config.toml"), SPLAT_WORKSHOP_STATE=str(STATE), FRAME_HOST="",
|
||||
PATH=f"{BIN}:{os.environ['PATH']}")
|
||||
os.environ.pop("SPLAT_LAN", None)
|
||||
|
||||
from splat_workshop import server # noqa: E402 (after the environment is set up)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean():
|
||||
"""Every test starts with empty runs, recordings, state and trash."""
|
||||
yield
|
||||
st = server.load_state().get("job") or {}
|
||||
if server.pid_alive(st.get("pid", 0)):
|
||||
server.cancel_job()
|
||||
wait_job(timeout=20)
|
||||
for d in (RUNS, REC, STATE, TRASH, FRAME_HOME):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
d.mkdir()
|
||||
for cache in (server.usage_cache, server.probe_cache, server.uploads, server.checks):
|
||||
cache.clear()
|
||||
for k in ("FAKE_PIPELINE", "FAKE_FRAME_OFF", "FAKE_FRAME_CUT", "FRAME_HOST"):
|
||||
os.environ.pop(k, None)
|
||||
os.environ["FRAME_HOST"] = ""
|
||||
TOOL_LOG.unlink(missing_ok=True)
|
||||
server.frame_cache.update(t=0, data=None)
|
||||
|
||||
|
||||
SHUTDOWNS = [] # calls of server.shutdown during the current test
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def never_exit(monkeypatch):
|
||||
"""Quitting must never end the test run – a broken guard would otherwise make it stop silently (green)."""
|
||||
SHUTDOWNS.clear()
|
||||
monkeypatch.setattr(server, "shutdown", lambda cancel: SHUTDOWNS.append(cancel))
|
||||
|
||||
def no_exit(code=0):
|
||||
raise RuntimeError(f"os._exit({code}) called during a test")
|
||||
monkeypatch.setattr(server.os, "_exit", no_exit)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def frame(monkeypatch):
|
||||
"""A Steam Frame (the fake one: a folder) is configured and reachable. Returns its home directory."""
|
||||
host = "steamos@frame.test"
|
||||
monkeypatch.setattr(server, "FRAME", host)
|
||||
monkeypatch.setattr(server, "SSH", [*server.SSH[:-1], host])
|
||||
os.environ["FRAME_HOST"] = host # for the scripts (splat.sh, to-frame.sh)
|
||||
for d in (server.FRAME_VIDEOS + "/thumbnails", server.FRAME_SCENES):
|
||||
(FRAME_HOME / d).mkdir(parents=True, exist_ok=True)
|
||||
return FRAME_HOME
|
||||
|
||||
|
||||
def tools_called():
|
||||
"""Calls of the fake tools so far, e.g. [["splat-transform", "-w", ...], ...]."""
|
||||
return [json.loads(line) for line in TOOL_LOG.read_text().splitlines()] if TOOL_LOG.exists() else []
|
||||
|
||||
|
||||
def wait_for_tool(name, timeout=5):
|
||||
"""Calls of one fake tool, waiting a little for ones started in the background (notifications)."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
calls = [c for c in tools_called() if c[0] == name]
|
||||
if calls:
|
||||
return calls
|
||||
time.sleep(0.1)
|
||||
return []
|
||||
|
||||
|
||||
def recording(name="2026-10-12_16-40-00_stereo.mp4"):
|
||||
p = REC / name
|
||||
p.write_bytes(b"not really a video")
|
||||
return p
|
||||
|
||||
|
||||
def wait_job(timeout=120):
|
||||
"""Wait until the current job has ended; returns its status."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
js = server.job_status(tail=True)
|
||||
if js and not js["running"]:
|
||||
return js
|
||||
time.sleep(0.3)
|
||||
raise TimeoutError("job did not finish")
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, port):
|
||||
self.port = port
|
||||
|
||||
def request(self, method, path, body=None, headers=None):
|
||||
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=30)
|
||||
data = body if isinstance(body, (bytes, type(None))) else json.dumps(body).encode()
|
||||
c.request(method, path, body=data, headers={"Content-Type": "application/json", **(headers or {})})
|
||||
r = c.getresponse()
|
||||
raw = r.read()
|
||||
try:
|
||||
return r.status, json.loads(raw)
|
||||
except ValueError:
|
||||
return r.status, raw
|
||||
|
||||
def get(self, path, headers=None):
|
||||
return self.request("GET", path, headers=headers)
|
||||
|
||||
def post(self, path, body=None, headers=None):
|
||||
return self.request("POST", path, body if body is not None else {}, headers)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def api():
|
||||
srv = server.http.server.ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
|
||||
srv.daemon_threads = True
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
yield Client(srv.server_address[1])
|
||||
srv.shutdown()
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
shutil.rmtree(BASE, ignore_errors=True)
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
"""Synthetic rooms in the shape the Arcturus pipeline leaves them, for tests without real recordings.
|
||||
|
||||
Two rooms side by side with a door between them (Y points down like in the pipeline):
|
||||
|
||||
z=3 +----------------+----------------+
|
||||
| | |
|
||||
| room A door room B |
|
||||
| (x 0..4) | (x 4..8) |
|
||||
z=0 +----------------+----------------+
|
||||
x=0 x=4 x=8
|
||||
|
||||
Every recording walks around its room; room B starts in room A next to the door and walks in
|
||||
(like a real recording that starts in the hallway). Each room gets what the pipeline writes:
|
||||
dataset/main.aiscene camera poses as tracked by the Frame (the truth, plus `drift`)
|
||||
dataset/sparse/0/images.bin camera poses of the reconstruction
|
||||
dataset/sparse/0/points3D.bin sparse points
|
||||
spirula/scene.ply splats (one per point)
|
||||
scene.sog, scene.xrSceneTransform.json, <slug>.ui.json
|
||||
The reconstruction is off by a known small rotation and shift (`error`), as a real one is before
|
||||
it is fitted to the Frame's tracking.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
FLOOR, CEILING = 1.6, -0.9 # y (down): floor below the cameras, ceiling above
|
||||
DOOR = (1.2, 2.0) # door between the rooms, z range
|
||||
PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
|
||||
PROPS = (["x", "y", "z", "nx", "ny", "nz", "f_dc_0", "f_dc_1", "f_dc_2"] + [f"f_rest_{i}" for i in range(45)]
|
||||
+ ["opacity", "scale_0", "scale_1", "scale_2", "rot_0", "rot_1", "rot_2", "rot_3"])
|
||||
|
||||
|
||||
def walls(x0, x1, step=0.04):
|
||||
"""Points on the walls, floor and ceiling of the room x0..x1 (z 0..3), with a door in the wall at x=4."""
|
||||
pts = []
|
||||
ys = np.arange(CEILING, FLOOR + 1e-9, step)
|
||||
for x in (x0, x1):
|
||||
for z in np.arange(0, 3 + 1e-9, step):
|
||||
if x == 4 and DOOR[0] < z < DOOR[1]:
|
||||
continue
|
||||
pts += [(x, y, z) for y in ys]
|
||||
for z in (0, 3):
|
||||
for x in np.arange(x0, x1 + 1e-9, step):
|
||||
pts += [(x, y, z) for y in ys]
|
||||
for x in np.arange(x0, x1 + 1e-9, step * 2):
|
||||
for z in np.arange(0, 3 + 1e-9, step * 2):
|
||||
pts += [(x, FLOOR, z), (x, CEILING, z)]
|
||||
return np.array(pts)
|
||||
|
||||
|
||||
def path(room, walk_in_offset=0.3):
|
||||
"""Camera positions over time (y = 0, head height) and the times in seconds."""
|
||||
t = np.linspace(0, 2 * np.pi, 140)
|
||||
if room == "A":
|
||||
# starts at the far wall, passes the door in the middle of the round
|
||||
loop = np.stack([2 + 1.3 * np.cos(t + np.pi), np.zeros_like(t), 1.5 + 0.9 * np.sin(t + np.pi)], 1)
|
||||
return loop, np.linspace(0, 40, len(loop))
|
||||
# starts in room A next to room A's round (like a recording that starts in the hallway), then through
|
||||
# the door. walk_in_offset: how far from room A's own path (towards room A's wall) it starts.
|
||||
tt = np.linspace(-1.0, 0, 18)
|
||||
along_a = np.stack([2 + 1.3 * np.cos(tt), np.zeros_like(tt), 1.5 + 0.9 * np.sin(tt) - walk_in_offset], 1)
|
||||
walk_in = np.vstack([along_a, np.stack([np.linspace(3.35, 4.6, 12), np.zeros(12), np.full(12, 1.6)], 1)])
|
||||
loop = np.stack([6 + 1.3 * np.cos(t + np.pi), np.zeros_like(t), 1.5 + 0.9 * np.sin(t + np.pi)], 1)
|
||||
pos = np.vstack([walk_in, loop])
|
||||
return pos, np.linspace(0, 45, len(pos))
|
||||
|
||||
|
||||
def look_at(c, target):
|
||||
"""cam_from_world rotation for a camera at c looking at target (x right, y down, z forward)."""
|
||||
z = target - c
|
||||
z[1] = 0
|
||||
z /= np.linalg.norm(z)
|
||||
y = np.array([0.0, 1.0, 0.0])
|
||||
x = np.cross(y, z)
|
||||
return np.stack([x, y, z])
|
||||
|
||||
|
||||
def write_images(path_, names, R_list, C_list):
|
||||
with open(path_, "wb") as f:
|
||||
f.write(struct.pack("<Q", len(names)))
|
||||
for i, (name, R, C) in enumerate(zip(names, R_list, C_list)):
|
||||
qx, qy, qz, qw = Rotation.from_matrix(R).as_quat()
|
||||
t = -R @ C
|
||||
f.write(struct.pack("<i7di", i + 1, qw, qx, qy, qz, *t, 1))
|
||||
f.write(name.encode() + b"\0")
|
||||
f.write(struct.pack("<Q", 0))
|
||||
|
||||
|
||||
def write_points(path_, pts):
|
||||
with open(path_, "wb") as f:
|
||||
f.write(struct.pack("<Q", len(pts)))
|
||||
for i, p in enumerate(pts):
|
||||
f.write(struct.pack("<Q3d3BdQ", i + 1, *p, 128, 128, 128, 0.5, 0))
|
||||
|
||||
|
||||
def write_ply(path_, pts, colours, rotation=(1.0, 0.0, 0.0, 0.0), scale=0.02):
|
||||
"""One splat per point; all with the same rotation (w, x, y, z) and size (metres)."""
|
||||
data = np.zeros(len(pts), dtype=np.dtype([(p, "<f4") for p in PROPS]))
|
||||
data["x"], data["y"], data["z"] = pts.T
|
||||
for k in range(3):
|
||||
data[f"f_dc_{k}"] = (colours[:, k] - 0.5) / 0.28209479177387814
|
||||
data["opacity"] = 3.0
|
||||
for k in range(3):
|
||||
data[f"scale_{k}"] = np.log(scale)
|
||||
for k in range(4):
|
||||
data[f"rot_{k}"] = rotation[k]
|
||||
header = "ply\nformat binary_little_endian 1.0\nelement vertex %d\n%send_header\n" % (
|
||||
len(pts), "".join(f"property float {p}\n" for p in PROPS))
|
||||
with open(path_, "wb") as f:
|
||||
f.write(header.encode())
|
||||
data.tofile(f)
|
||||
|
||||
|
||||
MARKERS = (((1, 0, 0), (0.0, 0.5, 0.0)), ((0, 1, 0), (2.0, 0.5, 0.0)), ((0, 0, 1), (0.0, 0.5, 2.0)))
|
||||
SPLAT_TURN = Rotation.from_euler("x", 20, degrees=True) # how every splat is turned in the real world
|
||||
SPLAT_SIZE = 0.02 # metres
|
||||
|
||||
|
||||
def make_room(runs: Path, slug: str, room: str, error=(1.0, (0.03, 0.0, -0.02)), scale=1.0,
|
||||
drift=(0.0, 0.0, 0.0), turn=0.0, noise=0.005, walk_in_offset=0.3,
|
||||
name=None, recording=None, markers=(), colour=None):
|
||||
"""Write one synthetic room into runs/<slug>. Returns the run folder.
|
||||
|
||||
The reconstruction differs from the real world by `error` = (yaw in degrees, shift in m) and `scale`.
|
||||
The Frame's tracking of this recording is off by `drift` (m) and turned by `turn` (degrees, about the
|
||||
room's middle), plus `noise` (m) per pose. markers: True for red/green/blue at the corner, along +x and
|
||||
along +z, or a list of ((r, g, b), (x, y, z)) – blobs of 400 splats at real-world positions.
|
||||
runs/<slug>/synth.json records how many splats are the room's own (of these, how many on the wall it
|
||||
shares with the other room – zero thickness, so it belongs to both) and how many were seen through the door."""
|
||||
rng = np.random.default_rng(zlib.crc32(slug.encode()))
|
||||
run = runs / slug
|
||||
sparse = run / "dataset" / "sparse" / "0"
|
||||
sparse.mkdir(parents=True, exist_ok=True)
|
||||
(run / "spirula").mkdir(exist_ok=True)
|
||||
x0, x1 = (0.0, 4.0) if room == "A" else (4.0, 8.0)
|
||||
pts = walls(x0, x1)
|
||||
# every recording its own colour, so the origin of a splat can be seen after putting rooms together
|
||||
colours = np.tile(colour or ((0.8, 0.3, 0.3) if room == "A" else (0.3, 0.3, 0.8)), (len(pts), 1))
|
||||
for c, at in (MARKERS if markers is True else markers):
|
||||
blob = np.array(at) + rng.normal(0, 0.05, (400, 3))
|
||||
pts, colours = np.vstack([pts, blob]), np.vstack([colours, np.tile(c, (400, 1))])
|
||||
own = len(pts)
|
||||
shared = int((np.abs(pts[:, 0] - 4) < 1e-9).sum()) # the wall between the rooms belongs to both
|
||||
# seen through the door: a little of the neighbouring room close to the door
|
||||
other = walls(4.0, 8.0) if room == "A" else walls(0.0, 4.0)
|
||||
near_door = other[(np.abs(other[:, 0] - 4) < 1.0) & (other[:, 2] > 0.6) & (other[:, 2] < 2.6)]
|
||||
pts = np.vstack([pts, near_door])
|
||||
colours = np.vstack([colours, np.tile(colours[0], (len(near_door), 1))])
|
||||
|
||||
pos, times = path(room, walk_in_offset)
|
||||
centre = np.array([(x0 + x1) / 2, 0.0, 1.5])
|
||||
yaw, shift = error
|
||||
E = Rotation.from_euler("y", yaw, degrees=True) # world -> reconstruction
|
||||
to_sfm = lambda p: scale * E.apply(p) + np.array(shift)
|
||||
T = Rotation.from_euler("y", turn, degrees=True) # the tracking's own turn
|
||||
names, Rs, Cs, frames = [], [], [], []
|
||||
for k, (c, t) in enumerate(zip(pos, times)):
|
||||
R_world = look_at(c.copy(), centre)
|
||||
image = f"cam0/{k:04d}-{t:.6f}.jpg"
|
||||
names.append(image)
|
||||
Rs.append(R_world @ E.as_matrix().T)
|
||||
Cs.append(to_sfm(c))
|
||||
world_from_cam = np.eye(4)
|
||||
world_from_cam[:3, :3] = T.as_matrix() @ R_world.T
|
||||
world_from_cam[:3, 3] = T.apply(c - centre) + centre + np.array(drift) + rng.normal(0, noise, 3)
|
||||
frames.append({"cam": 0, "path": "images/" + image, "worldFromCam": world_from_cam.tolist(),
|
||||
"playbackTimestampSec": float(t)})
|
||||
write_images(sparse / "images.bin", names, Rs, Cs)
|
||||
write_points(sparse / "points3D.bin", to_sfm(pts))
|
||||
q = (E * SPLAT_TURN).as_quat() # (x, y, z, w) in the reconstruction
|
||||
write_ply(run / "spirula" / "scene.ply", to_sfm(pts), colours, (q[3], q[0], q[1], q[2]), SPLAT_SIZE * scale)
|
||||
(run / "dataset" / "main.aiscene").write_text(json.dumps({"format": "synthetic", "cameras": [], "frames": frames}))
|
||||
(run / "scene.sog").write_bytes(b"synthetic")
|
||||
(run / "scene.xrSceneTransform.json").write_text(json.dumps({"transform": np.eye(4).tolist(), "worldScale": 1.0}))
|
||||
(run / f"{slug.lower()}.gallery.png").write_bytes(PNG)
|
||||
(run / "synth.json").write_text(json.dumps({"own": own, "shared_wall": shared, "seen_through_door": len(near_door),
|
||||
"splats": len(pts)}))
|
||||
(runs / f"{slug}.log").write_text(f"spirula: {len(pts):,} Gaussians in {run}/spirula/scene.ply\n")
|
||||
meta = {"name": name or slug, "recording": recording or f"2026-10-12_16-{40 + (room == 'B') * 3:02d}-00_stereo.mp4",
|
||||
"quality": "high"}
|
||||
(runs / f"{slug}.ui.json").write_text(json.dumps(meta))
|
||||
return run
|
||||
@@ -0,0 +1,103 @@
|
||||
"""The recording check's rating (splat_workshop/check.py): per-second measurements in, findings out.
|
||||
|
||||
Rows are made by hand with values clearly on one side of each limit, so the tests say what a user
|
||||
gets for a recording that is, say, 50 % dark – not what the code happens to compute."""
|
||||
import pytest
|
||||
|
||||
from splat_workshop import check
|
||||
|
||||
GOOD = {"bright": 120, "detail": 7.0, "motion": 10.0, "over": 0.0, "cast": 5, "rgb": (120, 120, 120),
|
||||
"right": {"bright": 120, "detail": 7.0}}
|
||||
|
||||
|
||||
def rows(n=60, **changes_by_second):
|
||||
"""n good seconds; changes_by_second: {"dark": range(...), ...} turns those seconds bad in one way."""
|
||||
out = [dict(GOOD, right=dict(GOOD["right"])) for _ in range(n)]
|
||||
for kind, secs in changes_by_second.items():
|
||||
for i in secs:
|
||||
r = out[i]
|
||||
if kind == "dark":
|
||||
r["bright"] = r["right"]["bright"] = 20
|
||||
elif kind == "flat":
|
||||
r["detail"] = r["right"]["detail"] = 1.0
|
||||
elif kind == "fast":
|
||||
r["motion"] = 80.0
|
||||
elif kind == "over":
|
||||
r["over"] = 0.2
|
||||
elif kind == "eye": # one lens covered
|
||||
r["right"] = {"bright": 30, "detail": 0.5}
|
||||
return out
|
||||
|
||||
|
||||
def findings(res):
|
||||
return {f["code"]: f["level"] for f in res["findings"]}
|
||||
|
||||
|
||||
def test_a_good_recording():
|
||||
res = check.evaluate(rows(), 60)
|
||||
assert res["level"] == "ok" and res["findings"] == [] and set(res["timeline"]) == {"ok"}
|
||||
assert res["summary"] == {"bright": 120, "detail": 7.0, "seconds": 60}
|
||||
|
||||
|
||||
def test_nothing_measured():
|
||||
assert check.evaluate([], 0)["level"] == "bad"
|
||||
|
||||
|
||||
def test_too_dark_overall():
|
||||
dark = [dict(r, bright=20, right={"bright": 20, "detail": 7.0}) for r in rows()] # both lenses
|
||||
assert findings(check.evaluate(dark, 60)) == {"too_dark": "bad"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("secs,level", [(range(0, 30), "bad"), # half the recording dark
|
||||
(range(0, 24), "warn"), # 40 %: not more than 40 %
|
||||
(range(0, 3), "warn")])
|
||||
def test_dark_stretches(secs, level):
|
||||
res = check.evaluate(rows(dark=secs), 60)
|
||||
assert findings(res)["dark"] == level
|
||||
f = next(f for f in res["findings"] if f["code"] == "dark")
|
||||
assert f["secs"] == len(secs) and f["spans"] == [(secs[0], secs[-1])]
|
||||
assert res["timeline"][secs[0]] == "dark"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("secs,level", [(range(10, 40), "bad"), (range(10, 15), "warn"), (range(10, 13), "info")])
|
||||
def test_plain_surfaces(secs, level):
|
||||
# 30 s = 50 %: bad; 5 s = 8 %: warn; 3 s = 5 %: only a note
|
||||
assert findings(check.evaluate(rows(flat=secs), 60))["flat"] == level
|
||||
|
||||
|
||||
def test_a_single_plain_second_is_not_worth_a_finding():
|
||||
assert "flat" not in findings(check.evaluate(rows(flat=[10]), 60))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("secs,level", [(range(0, 12), "warn"), (range(0, 5), "info")])
|
||||
def test_fast_pans(secs, level):
|
||||
assert findings(check.evaluate(rows(fast=secs), 60))["fast"] == level
|
||||
|
||||
|
||||
def test_a_covered_lens_needs_three_seconds():
|
||||
assert findings(check.evaluate(rows(eye=range(5, 8)), 60))["cameras_differ"] == "warn"
|
||||
assert "cameras_differ" not in findings(check.evaluate(rows(eye=range(5, 7)), 60))
|
||||
|
||||
|
||||
def test_overexposure_and_colour_cast_are_notes_only():
|
||||
tinted = [dict(r, cast=30, rgb=(160, 140, 80)) for r in rows(over=range(0, 5))]
|
||||
res = check.evaluate(tinted, 60)
|
||||
assert findings(res) == {"overexposed": "info", "color_cast": "info"}
|
||||
assert res["level"] == "ok"
|
||||
assert next(f for f in res["findings"] if f["code"] == "color_cast")["tone"] == "yellow"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("duration,code,level", [(15, "short", "warn"), (200, "long", "info")])
|
||||
def test_length(duration, code, level):
|
||||
assert findings(check.evaluate(rows(), duration))[code] == level
|
||||
|
||||
|
||||
def test_the_worst_finding_decides():
|
||||
res = check.evaluate(rows(dark=range(0, 30), fast=range(40, 45)), 60)
|
||||
assert res["level"] == "bad"
|
||||
assert res["timeline"][0] == "dark" and res["timeline"][42] == "fast" and res["timeline"][50] == "ok"
|
||||
|
||||
|
||||
def test_spans():
|
||||
assert check._spans([False, True, True, False, True]) == [(1, 2), (4, 4)]
|
||||
assert check._spans([True, False, True, True], min_len=2) == [(2, 3)]
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Putting rooms together (scripts/combine.py, scripts/combine.sh) on synthetic rooms with a known error.
|
||||
|
||||
The expectations come from how the synthetic rooms were built (tests/synth.py), not from the code:
|
||||
each reconstruction is turned, shifted and scaled by a known amount, the Frame's tracking has 5 mm
|
||||
noise and optionally a drift or a turn of its own."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
from conftest import BASE, ROOT, RUNS, tools_called
|
||||
import synth
|
||||
|
||||
COMBINE = ROOT / "scripts" / "combine.py"
|
||||
DRIFT_B = 0.03 # metres: the Frame's tracking of room B is off by this much
|
||||
|
||||
|
||||
def two_rooms(drift_b=DRIFT_B, turn_b=0.0, b_far=False, walk_in_offset=0.3, scale_a=1.0, markers_b=()):
|
||||
a = synth.make_room(RUNS, "A", "A", error=(1.0, (0.03, 0.0, -0.02)), scale=scale_a,
|
||||
markers=[((0, 1, 0), (1.0, 0.5, 0.3)), ((0, 1, 1), (1.0, 0.5, 2.7))])
|
||||
b = synth.make_room(RUNS, "B", "B", error=(-0.8, (-0.02, 0.01, 0.03)), turn=turn_b,
|
||||
drift=(20.0 if b_far else drift_b, 0.0, 0.0), walk_in_offset=walk_in_offset,
|
||||
markers=markers_b)
|
||||
return a, b
|
||||
|
||||
|
||||
def combine(*runs, align="images", partition="view"):
|
||||
out = BASE / f"out-{align}-{partition}"
|
||||
r = subprocess.run([sys.executable, str(COMBINE), str(out), *[f"{p}={p.name}" for p in runs],
|
||||
"--align", align, "--partition", partition], capture_output=True, text=True)
|
||||
assert r.returncode == 0 or "@@ error" in r.stdout, r.stderr
|
||||
return out, r
|
||||
|
||||
|
||||
def result(out):
|
||||
return json.loads((out / "combine.json").read_text())
|
||||
|
||||
|
||||
def read_ply(path):
|
||||
with open(path, "rb") as f:
|
||||
header = b""
|
||||
while not header.endswith(b"end_header\n"):
|
||||
header += f.readline()
|
||||
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])
|
||||
return np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
|
||||
|
||||
|
||||
def blob(v, rgb):
|
||||
"""Centre of the marker splats of one colour (markers are pure colours, rooms are not)."""
|
||||
c = 0.5 + 0.28209479177387814 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1)
|
||||
m = np.all(np.abs(c - np.array(rgb)) < 0.02, axis=1)
|
||||
assert m.sum() >= 300, f"marker {rgb} not found"
|
||||
return np.stack([v["x"], v["y"], v["z"]], 1)[m].mean(0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("align,lo,hi", [
|
||||
("frame", 5.0, 12.0), # as delivered: both reconstructions' errors add up
|
||||
("images", 2.0, 4.0), # rooms fitted, but room B's tracking drift (3 cm) is left
|
||||
("shift", 0.0, 1.0), # shifting the rooms over the door removes the drift
|
||||
("rigid", 0.0, 1.0),
|
||||
])
|
||||
def test_corrections_take_out_the_known_error(align, lo, hi):
|
||||
out, r = combine(*two_rooms(), align=align)
|
||||
seam = result(out)["seams"]["A|B"]["cm"]
|
||||
assert lo <= seam <= hi, f"{align}: overlap off by {seam} cm"
|
||||
|
||||
|
||||
def test_every_room_is_fitted_to_the_frames_tracking():
|
||||
out, r = combine(*two_rooms(drift_b=0.0, scale_a=1.03))
|
||||
for room in result(out)["rooms"].values():
|
||||
assert room["tracking_cm"] > 3 # off before: turned, shifted (and room A 3 % too big)
|
||||
assert room["fitted_cm"] < 1.5 # fitted over all images: down to the 5 mm tracking noise
|
||||
|
||||
|
||||
def test_splats_get_the_rotation_and_size_they_have_in_reality():
|
||||
"""Room A's reconstruction is 3 % too big and turned by 1°; afterwards every splat must have its
|
||||
real-world orientation (20° about x) and size (2 cm) again – with the right sign."""
|
||||
out, r = combine(*two_rooms(drift_b=0.0, scale_a=1.03))
|
||||
v = read_ply(out / "spirula" / "scene.ply")
|
||||
q = np.stack([v["rot_1"], v["rot_2"], v["rot_3"], v["rot_0"]], 1).astype(np.float64)
|
||||
off = (Rotation.from_quat(q) * synth.SPLAT_TURN.inv()).magnitude()
|
||||
assert np.degrees(off).max() < 0.3, "splats are turned the wrong way"
|
||||
size = np.exp(np.stack([v["scale_0"], v["scale_1"], v["scale_2"]], 1).astype(np.float64))
|
||||
assert np.allclose(size, synth.SPLAT_SIZE, rtol=0.01), "splats have the wrong size"
|
||||
# and the room itself has its real size: the two markers in room A are 2.4 m apart
|
||||
assert abs(np.linalg.norm(blob(v, (0, 1, 1)) - blob(v, (0, 1, 0))) - 2.4) < 0.02
|
||||
|
||||
|
||||
def test_shift_and_turn_also_takes_out_a_turned_tracking():
|
||||
"""Room B's tracking is turned by 2° about the room's middle: shifting (C) cannot repair that,
|
||||
shifting and turning (D) can reduce it. Measured independently of the alignment: the distances from
|
||||
the markers in room A to a marker in the far corner of room B (rigid motions keep distances).
|
||||
Known limit: the turn can only be read from the overlap at the door, so D does not take it out
|
||||
completely (here about 2 cm are left of about 6 cm)."""
|
||||
far_b = ((1, 0, 1), (7.5, 0.5, 2.7))
|
||||
truth = [np.linalg.norm(np.array(far_b[1]) - np.array(p)) for p in ((1.0, 0.5, 0.3), (1.0, 0.5, 2.7))]
|
||||
|
||||
def error(align):
|
||||
out, r = combine(*two_rooms(drift_b=0.0, turn_b=2.0, markers_b=[far_b]), align=align)
|
||||
v = read_ply(out / "spirula" / "scene.ply")
|
||||
got = [np.linalg.norm(blob(v, far_b[0]) - blob(v, c)) for c in ((0, 1, 0), (0, 1, 1))]
|
||||
return max(abs(g - t) for g, t in zip(got, truth)) * 100
|
||||
|
||||
shift, rigid = error("shift"), error("rigid")
|
||||
assert shift > 3, f"the test needs a turn that shifting cannot repair ({shift:.1f} cm)"
|
||||
assert rigid < shift / 2, f"shift {shift:.1f} cm, shift and turn {rigid:.1f} cm"
|
||||
|
||||
|
||||
def test_every_splat_is_written_once():
|
||||
a, b = two_rooms()
|
||||
out, r = combine(a, b)
|
||||
info, v = result(out), read_ply(out / "spirula" / "scene.ply")
|
||||
assert len(v) == info["splats"]
|
||||
for run in (a, b):
|
||||
made = json.loads((run / "synth.json").read_text())
|
||||
room = info["rooms"][run.name]
|
||||
assert room["splats"] == made["splats"] # read completely
|
||||
# a room keeps its own splats (the shared wall may go either way); of what it saw of the neighbour
|
||||
# through the door it keeps at most the part right behind the door, where its own cameras were closer
|
||||
assert 0.95 * (made["own"] - made["shared_wall"]) <= room["kept"] <= made["own"] + made["seen_through_door"]
|
||||
# each place once: together the rooms keep no more than they are made of
|
||||
total_own = sum(json.loads((run / "synth.json").read_text())["own"] for run in (a, b))
|
||||
assert len(v) <= total_own
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offset", [0.1, 0.3])
|
||||
def test_borders_run_through_the_door(offset):
|
||||
"""Room B starts its recording in room A (walking in). With borders 'through doors' that stretch
|
||||
claims nothing, so room A comes from room A's own recording; 'nearest camera' cuts into room A."""
|
||||
def from_b_in_room_a(partition):
|
||||
out, r = combine(*two_rooms(drift_b=0.0, walk_in_offset=offset), partition=partition)
|
||||
v = read_ply(out / "spirula" / "scene.ply")
|
||||
inside_a = (v["x"] > 0.2) & (v["x"] < 3.6)
|
||||
c = 0.5 + 0.28209479177387814 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1)
|
||||
from_b = np.all(np.abs(c - (0.3, 0.3, 0.8)) < 0.02, axis=1) # room B's own colour
|
||||
return int((inside_a & from_b).sum()), result(out)["rooms"]["B"]["walk_in_out_cameras"]
|
||||
view, walk_in = from_b_in_room_a("view")
|
||||
assert walk_in > 10 # recognised as walking in
|
||||
assert view < 50 # practically nothing of room B inside room A
|
||||
if offset >= 0.3: # close to the round, room A's cameras win anyway
|
||||
nearest, _ = from_b_in_room_a("nearest")
|
||||
assert nearest > 10 * max(view, 5)
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=True, reason="known limit: walking in is only recognised up to 0.4 m from the other "
|
||||
"recording's round; further away the walk-in stretch claims room A's wall")
|
||||
def test_borders_with_a_walk_in_far_from_the_other_round():
|
||||
out, r = combine(*two_rooms(drift_b=0.0, walk_in_offset=0.6))
|
||||
assert result(out)["rooms"]["B"]["walk_in_out_cameras"] > 10
|
||||
|
||||
|
||||
def test_rooms_from_different_sessions_are_refused():
|
||||
out, r = combine(*two_rooms(b_far=True))
|
||||
assert r.returncode != 0
|
||||
assert "@@ error not_connected" in r.stdout
|
||||
|
||||
|
||||
def run_combine_sh(env, *runs):
|
||||
return subprocess.run([str(ROOT / "scripts" / "combine.sh"), "Tour", "Tour", *[f"{p}={p.name}" for p in runs]],
|
||||
env={**os.environ, "NO_UPLOAD": "1", **env}, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_combine_sh_reduces_for_the_frame_and_can_skip_the_full_version():
|
||||
a, b = two_rooms()
|
||||
r = run_combine_sh({"FRAME_SPLATS": "10000", "FULL": "0"}, a, b)
|
||||
assert r.returncode == 0, r.stdout[-2000:] + r.stderr[-2000:]
|
||||
calls = [c[1:] for c in tools_called() if c[0] == "splat-transform"]
|
||||
tmp = str(RUNS / ".building" / "Tour")
|
||||
assert calls[0] == ["-w", f"{tmp}/spirula/scene.ply", "--decimate", "10000", f"{tmp}/frame.ply"]
|
||||
assert calls[1] == ["-w", f"{tmp}/frame.ply", f"{tmp}/scene.sog"]
|
||||
assert len(calls) == 2 # no full version
|
||||
tour = RUNS / "Tour"
|
||||
assert (tour / "scene.sog").exists() and not (tour / "full").exists() and not (tour / "frame.ply").exists()
|
||||
assert not (RUNS / ".building").exists()
|
||||
|
||||
|
||||
def test_combine_sh_full_version_without_reducing_a_small_tour():
|
||||
a, b = two_rooms()
|
||||
r = run_combine_sh({"FRAME_SPLATS": "3500000"}, a, b)
|
||||
assert r.returncode == 0, r.stdout[-2000:] + r.stderr[-2000:]
|
||||
calls = [c[1:] for c in tools_called() if c[0] == "splat-transform"]
|
||||
assert not any("--decimate" in c for c in calls) # fewer splats than the Frame version allows
|
||||
assert (RUNS / "Tour" / "full" / "tour-full.sog").exists()
|
||||
assert (RUNS / "Tour" / "full" / "tour-full.sceneinfo.png").exists()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""The floor plan is a top view, not its mirror image (scripts/floorplan.py).
|
||||
|
||||
A plan may be turned (so the walls are straight), but never mirrored. Three coloured markers tell
|
||||
the two apart: red at the corner, green along +x, blue along +z. Turning keeps the sense of
|
||||
red -> green -> blue, mirroring reverses it. Seen from above with the y axis pointing down
|
||||
(like the pipeline's frame), +x to +z turns counter-clockwise on screen.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from conftest import BASE, ROOT
|
||||
import synth
|
||||
|
||||
|
||||
def draw(style):
|
||||
runs = BASE / f"plan-{style}"
|
||||
synth.make_room(runs, "Room", "A", error=(0.0, (0, 0, 0)), markers=True)
|
||||
out = runs / "plan.png"
|
||||
r = subprocess.run([sys.executable, str(ROOT / "scripts" / "floorplan.py"), str(out), f"{runs / 'Room'}=Room",
|
||||
"--style", style], capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stderr
|
||||
return np.asarray(Image.open(out).convert("RGB")).astype(int)
|
||||
|
||||
|
||||
def test_floor_plan_is_not_mirrored():
|
||||
img = draw("pretty") # the pretty style shows the splats' own colours
|
||||
rr, gg, bb = img[..., 0], img[..., 1], img[..., 2]
|
||||
|
||||
def centre(mask):
|
||||
ys, xs = np.nonzero(mask)
|
||||
assert len(xs) > 50, "marker not found"
|
||||
return np.array([xs.mean(), ys.mean()])
|
||||
red = centre((rr > 150) & (gg < 90) & (bb < 90))
|
||||
green = centre((gg > 150) & (rr < 90) & (bb < 90))
|
||||
blue = centre((bb > 150) & (rr < 90) & (gg < 90))
|
||||
gx, gz = green - red, blue - red # screen directions of +x and +z (rows count downwards)
|
||||
cross = gx[0] * gz[1] - gx[1] * gz[0]
|
||||
assert cross < 0, "the floor plan is mirrored"
|
||||
|
||||
|
||||
def test_classic_style_smoke():
|
||||
"""Smoke test: the classic style draws something (its colours are per room, not checkable here)."""
|
||||
img = draw("classic")
|
||||
assert img.shape[0] > 500 and img.std() > 5 # not an empty picture
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Groups of scenes: one group per scene, suggestions, renaming, cleaning up together."""
|
||||
import json
|
||||
|
||||
from conftest import RUNS, server
|
||||
import synth
|
||||
|
||||
|
||||
def make_flat():
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen", recording="2026-10-12_16-40-00_stereo.mp4")
|
||||
synth.make_room(RUNS, "Hallway", "B", name="Hallway", recording="2026-10-12_16-43-00_stereo.mp4")
|
||||
synth.make_room(RUNS, "Statue", "A", name="Statue", recording="2026-10-03_19-00-00_stereo.mp4")
|
||||
|
||||
|
||||
def test_rooms_recorded_one_after_another_are_suggested(api):
|
||||
make_flat()
|
||||
# 25 minutes after the hallway: more than the 20 minutes that still count as one session
|
||||
synth.make_room(RUNS, "Garden", "A", name="Garden", recording="2026-10-12_17-08-00_stereo.mp4")
|
||||
status, r = api.get("/api/groups")
|
||||
assert status == 200 and r["groups"] == []
|
||||
assert sorted(r["suggestion"]["members"]) == ["Hallway", "Kitchen"] # not the statue from another day
|
||||
assert r["suggestion"]["date"] == "2026-10-12"
|
||||
assert api.post("/api/group/dismiss", {"key": r["suggestion"]["key"]})[0] == 200
|
||||
assert api.get("/api/groups")[1]["suggestion"] is None
|
||||
|
||||
|
||||
def test_a_group_with_its_scenes(api):
|
||||
make_flat()
|
||||
status, r = api.post("/api/group", {"name": "Example home", "members": "Kitchen,Hallway"})
|
||||
assert status == 200 and r["slug"] == "Example-home"
|
||||
g = api.get("/api/groups")[1]["groups"][0]
|
||||
assert (g["name"], g["members"], g["result"]) == ("Example home", ["Kitchen", "Hallway"], None)
|
||||
assert "Example-home" not in {s["slug"] for s in api.get("/api/scenes")[1]} # a group is not a scene
|
||||
assert "Example-home" not in {u["slug"] for u in api.get("/api/unfinished")[1]}
|
||||
|
||||
|
||||
def test_a_scene_can_only_be_in_one_group(api):
|
||||
make_flat()
|
||||
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hallway"})
|
||||
status, r = api.post("/api/group", {"name": "Other", "members": "Kitchen,Statue"})
|
||||
assert status == 400 and r["error"]["code"] == "err.in_other_group"
|
||||
assert api.post("/api/group", {"name": "Empty", "members": ""})[0] == 400
|
||||
assert api.post("/api/group", {"name": "Ghost", "members": "Nope"})[0] == 400
|
||||
|
||||
|
||||
def test_renaming_and_changing_a_group(api):
|
||||
make_flat()
|
||||
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hallway"})
|
||||
status, r = api.post("/api/group", {"slug": "Home", "name": "Old flat", "members": "Kitchen"})
|
||||
assert status == 200 and r["slug"] == "Old-flat"
|
||||
assert not (RUNS / "Home.ui.json").exists()
|
||||
g = api.get("/api/groups")[1]["groups"][0]
|
||||
assert (g["slug"], g["name"], g["members"]) == ("Old-flat", "Old flat", ["Kitchen"])
|
||||
|
||||
|
||||
def test_renamed_and_deleted_scenes_follow_their_group(api):
|
||||
make_flat()
|
||||
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hallway"})
|
||||
api.post("/api/rename", {"slug": "Kitchen", "new": "Küche"})
|
||||
assert json.loads((RUNS / "Home.ui.json").read_text())["members"] == ["Kueche", "Hallway"]
|
||||
api.post("/api/delete", {"slug": "Hallway"})
|
||||
assert json.loads((RUNS / "Home.ui.json").read_text())["members"] == ["Kueche"]
|
||||
|
||||
|
||||
def test_cleaning_up_a_group_cleans_only_its_scenes(api):
|
||||
make_flat()
|
||||
for slug in ("Kitchen", "Hallway", "Statue"):
|
||||
(RUNS / slug / "spirula" / "training").mkdir(parents=True)
|
||||
(RUNS / slug / "spirula" / "training" / "x").write_bytes(b"x" * 1000)
|
||||
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hallway"})
|
||||
assert api.post("/api/clean", {"slug": "Home", "what": "temp"})[0] == 200
|
||||
assert not (RUNS / "Kitchen/spirula/training").exists() and not (RUNS / "Hallway/spirula/training").exists()
|
||||
assert (RUNS / "Statue/spirula/training/x").exists()
|
||||
|
||||
|
||||
def test_dissolving_a_group_keeps_its_scenes(api):
|
||||
make_flat()
|
||||
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hallway"})
|
||||
assert api.post("/api/delete", {"slug": "Home"})[0] == 200
|
||||
assert api.get("/api/groups")[1]["groups"] == []
|
||||
assert {"Kitchen", "Hallway", "Statue"} <= {s["slug"] for s in server.scenes()}
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Conversions and tours as real background jobs (scripts/splat.sh, scripts/combine.sh) with a fake pipeline."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import RUNS, recording, server, wait_for_tool, wait_job
|
||||
import synth
|
||||
|
||||
|
||||
def convert(name="Kitchen", existing=None, quality="fast", rec=None):
|
||||
rec = rec or recording()
|
||||
e = server.start_job({"path": str(rec), "name": name, "quality": quality, "upload": False,
|
||||
**({"existing": existing} if existing else {})})
|
||||
assert e is None, e
|
||||
return wait_job()
|
||||
|
||||
|
||||
def test_conversion_makes_a_scene():
|
||||
js = convert()
|
||||
assert js["result"] == "ok", js["tail"]
|
||||
assert js["info"]["cameras"] == 20 and js["info"]["rms_cm"] == 1.1
|
||||
s = {x["slug"]: x for x in server.scenes()}["Kitchen"]
|
||||
assert s["name"] == "Kitchen" and s["gaussians"] == 1000 and s["preview"]
|
||||
|
||||
|
||||
def test_a_second_run_of_the_same_name_asks_first():
|
||||
convert()
|
||||
assert server.start_job({"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False}) == "exists"
|
||||
|
||||
|
||||
def test_start_over_that_fails_keeps_the_old_scene():
|
||||
convert(quality="fast")
|
||||
before = (RUNS / "Kitchen" / "scene.sog").stat().st_mtime_ns
|
||||
os.environ["FAKE_PIPELINE"] = "fail"
|
||||
js = convert(existing="fresh", quality="high")
|
||||
assert js["result"] == "failed"
|
||||
assert (RUNS / "Kitchen" / "scene.sog").stat().st_mtime_ns == before # the old result is back
|
||||
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["quality"] == "fast" # with its settings
|
||||
assert not (RUNS / ".previous").exists()
|
||||
|
||||
|
||||
def test_cancelled_start_over_keeps_the_old_scene():
|
||||
convert()
|
||||
before = (RUNS / "Kitchen" / "scene.sog").stat().st_mtime_ns
|
||||
os.environ["FAKE_PIPELINE"] = "slow"
|
||||
assert server.start_job({"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False,
|
||||
"existing": "fresh"}) is None
|
||||
time.sleep(3)
|
||||
# while it computes, the old scene stays in the list, marked
|
||||
assert {s["slug"]: s["replacing"] for s in server.scenes()}.get("Kitchen") is True
|
||||
assert server.cancel_job() is None
|
||||
js = wait_job(timeout=30)
|
||||
assert js["result"] == "cancelled"
|
||||
assert (RUNS / "Kitchen" / "scene.sog").stat().st_mtime_ns == before
|
||||
|
||||
|
||||
def test_start_over_that_works_replaces_the_scene():
|
||||
convert()
|
||||
before = (RUNS / "Kitchen" / "scene.sog").stat().st_mtime_ns
|
||||
time.sleep(0.05)
|
||||
assert convert(existing="fresh")["result"] == "ok"
|
||||
assert (RUNS / "Kitchen" / "scene.sog").stat().st_mtime_ns != before
|
||||
assert not (RUNS / ".previous").exists()
|
||||
|
||||
|
||||
def test_resume_after_an_interrupted_training():
|
||||
rec = recording()
|
||||
(RUNS / "Kitchen" / "dataset").mkdir(parents=True)
|
||||
(RUNS / "Kitchen" / "dataset" / "spirula-dataset.json").write_text('{"status": "complete"}')
|
||||
(RUNS / "Kitchen" / "spirula" / "training" / "step-18000.ckpt").mkdir(parents=True)
|
||||
(RUNS / "Kitchen" / "spirula" / "run.json").write_text('{"status": "running"}')
|
||||
(RUNS / "Kitchen.ui.json").write_text(json.dumps({"name": "Kitchen", "recording": rec.name, "quality": "fast"}))
|
||||
assert server.resume_run("Kitchen", fresh=False) is None
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok", js["tail"]
|
||||
# the (fake) pipeline refuses --resume with an unfinished training, like the real one: it only
|
||||
# worked because splat.sh started the training over
|
||||
assert "Training was interrupted (last checkpoint: 18000.ckpt)" in js["tail"]
|
||||
assert "Incomplete training retained" not in js["tail"]
|
||||
assert not (RUNS / "Kitchen" / "spirula" / "training").exists()
|
||||
|
||||
|
||||
def test_putting_a_tour_together():
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen", recording="2026-10-12_16-40-00_stereo.mp4")
|
||||
synth.make_room(RUNS, "Hallway", "B", name="Hallway", recording="2026-10-12_16-43-00_stereo.mp4", drift=(0.03, 0, 0))
|
||||
assert server.save_group("", "Example home", "Kitchen,Hallway") == "Example-home"
|
||||
assert server.start_combine({"slug": "Example-home", "align": "shift", "partition": "view",
|
||||
"frame_splats": "500000", "full": True}) is None
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok", js["tail"]
|
||||
g = server.groups()["groups"][0]
|
||||
r = g["result"]
|
||||
assert r and r["splats"] > 10000 and r["align"] == "shift" and not r["stale"] and r["preview"]
|
||||
assert r["seams"]["Kitchen|Hallway"]["cm"] < 0.5
|
||||
tour = RUNS / "Example-home"
|
||||
assert (tour / "example-home.sog").exists() and (tour / "example-home.sceneinfo.png").exists()
|
||||
assert (tour / "full" / "example-home-full.sog").exists()
|
||||
# a scene that changes afterwards makes the tour stale
|
||||
time.sleep(0.05)
|
||||
(RUNS / "Kitchen" / "scene.sog").write_bytes(b"newer")
|
||||
assert server.groups()["groups"][0]["result"]["stale"]
|
||||
|
||||
|
||||
def test_a_tour_needs_two_scenes_and_no_other_job():
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
server.save_group("", "Lonely", "Kitchen")
|
||||
assert server.start_combine({"slug": "Lonely"})["code"] == "err.combine_too_few"
|
||||
assert server.start_combine({"slug": "Lonely", "align": "magic"})["code"] == "err.bad_request"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- checks before a conversion starts
|
||||
|
||||
@pytest.mark.parametrize("file,code", [
|
||||
("2026-10-12_16-40-00_mono.mp4", "probe.not_stereo"),
|
||||
("2026-10-12_16-40-00_nogpmd.mp4", "probe.no_gpmd"),
|
||||
("2026-10-12_16-40-00_short.mp4", "probe.too_short"),
|
||||
])
|
||||
def test_unsuitable_recordings_are_refused(api, file, code):
|
||||
rec = recording(file)
|
||||
status, r = api.post("/api/start", {"path": str(rec), "name": "Kitchen", "quality": "fast", "upload": False})
|
||||
assert status == 400 and r["error"]["code"] == code
|
||||
assert not (RUNS / "Kitchen").exists()
|
||||
|
||||
|
||||
def test_a_short_recording_only_warns():
|
||||
p = server.probe(str(recording("2026-10-12_16-40-00_brief.mp4")))
|
||||
assert p["ok"] and p["warnings"][0]["code"] == "probe.short"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,code", [
|
||||
({"path": "/etc/passwd"}, "err.unknown_recording"), # only recordings from the known folders
|
||||
({"quality": "bogus"}, "err.bad_quality"),
|
||||
({"frames": "9999"}, "err.range"),
|
||||
({"scale": "abc"}, "err.bad_value"),
|
||||
({"name": "///"}, "err.bad_name"),
|
||||
])
|
||||
def test_invalid_conversions_are_refused(body, code):
|
||||
full = {"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False, **body}
|
||||
assert server.start_job(full)["code"] == code
|
||||
assert server.load_state().get("job") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- a scene that is being computed is left alone
|
||||
|
||||
def test_a_scene_being_computed_cannot_be_changed(api):
|
||||
convert() # a finished scene ...
|
||||
os.environ["FAKE_PIPELINE"] = "slow" # ... computed again (resume keeps scene.sog meanwhile)
|
||||
assert server.start_job({"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False,
|
||||
"existing": "resume"}) is None
|
||||
time.sleep(2)
|
||||
for path, body in (("/api/delete", {"slug": "Kitchen"}), ("/api/rename", {"slug": "Kitchen", "new": "Other"}),
|
||||
("/api/clean", {"slug": "Kitchen", "what": "temp"})):
|
||||
status, r = api.post(path, body)
|
||||
assert status == 400 and r["error"]["code"] == "err.scene_busy", path
|
||||
# and no tour is put together meanwhile (one job at a time)
|
||||
synth.make_room(RUNS, "Office", "A")
|
||||
synth.make_room(RUNS, "Hallway", "B")
|
||||
assert server.save_group("", "Home", "Office,Hallway") == "Home"
|
||||
assert server.start_combine({"slug": "Home"})["code"] == "err.job_busy"
|
||||
assert (RUNS / "Kitchen" / "scene.sog").exists() and (RUNS / "Kitchen.log").exists()
|
||||
server.cancel_job()
|
||||
wait_job(timeout=30)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- after a power cut
|
||||
|
||||
def leftover_from_a_power_cut():
|
||||
"""A start over that never finished: the old scene sits in .previous, a half new run in its place."""
|
||||
convert(quality="fast")
|
||||
old = (RUNS / "Kitchen" / "scene.sog").read_bytes()
|
||||
prev = RUNS / ".previous" / "Kitchen"
|
||||
prev.mkdir(parents=True)
|
||||
(RUNS / "Kitchen").rename(prev / "run")
|
||||
(RUNS / "Kitchen.log").rename(prev / "log")
|
||||
(RUNS / "Kitchen").mkdir()
|
||||
(RUNS / "Kitchen" / "half.txt").write_text("half")
|
||||
meta = json.loads((RUNS / "Kitchen.ui.json").read_text())
|
||||
(RUNS / "Kitchen.ui.json").write_text(json.dumps({**meta, "quality": "high", "previous": meta}))
|
||||
return old
|
||||
|
||||
|
||||
def test_the_server_puts_back_a_scene_left_after_a_power_cut():
|
||||
old = leftover_from_a_power_cut()
|
||||
server.restore_previous()
|
||||
assert (RUNS / "Kitchen" / "scene.sog").read_bytes() == old
|
||||
assert not (RUNS / "Kitchen" / "half.txt").exists()
|
||||
assert (RUNS / "Kitchen.log").exists()
|
||||
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["quality"] == "fast"
|
||||
assert not (RUNS / ".previous").exists()
|
||||
|
||||
|
||||
def test_start_over_after_a_power_cut_keeps_the_old_scene_if_it_fails():
|
||||
old = leftover_from_a_power_cut()
|
||||
os.environ["FAKE_PIPELINE"] = "fail"
|
||||
js = convert(existing="fresh")
|
||||
assert js["result"] == "failed"
|
||||
assert (RUNS / "Kitchen" / "scene.sog").read_bytes() == old # splat.sh first put the left-over back
|
||||
|
||||
|
||||
def test_start_over_from_the_list_of_cancelled_runs():
|
||||
rec = recording()
|
||||
# stopped while extracting images: what the pipeline leaves behind (state "running", half the images)
|
||||
(RUNS / "Kitchen" / "dataset" / "images" / "cam0").mkdir(parents=True)
|
||||
(RUNS / "Kitchen" / "dataset" / "images" / "cam0" / "0000.jpg").write_bytes(b"x")
|
||||
(RUNS / "Kitchen" / "dataset" / "spirula-dataset.json").write_text('{"status": "running"}')
|
||||
(RUNS / "Kitchen.log").write_text("Extraction progress: decoded 4% / saved 44 of 1000\n")
|
||||
(RUNS / "Kitchen.ui.json").write_text(json.dumps({"name": "Kitchen", "recording": rec.name, "quality": "schnell"}))
|
||||
assert server.resume_run("Kitchen", fresh=False)["code"] == "err.not_resumable"
|
||||
assert server.resume_run("Kitchen", fresh=True) is None
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok" and js["quality"] == "fast" # settings of the first version understood
|
||||
assert "Kitchen" in {s["slug"] for s in server.scenes()}
|
||||
assert "Kitchen" not in {u["slug"] for u in server.unfinished_runs()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- after a job: the watcher
|
||||
|
||||
def test_after_a_job_the_watcher_notifies_and_tidies_up():
|
||||
convert()
|
||||
os.environ["FAKE_PIPELINE"] = "ok"
|
||||
assert server.start_job({"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False,
|
||||
"existing": "fresh"}) is None
|
||||
assert server.watch_once(None) is True # running
|
||||
wait_job()
|
||||
assert "previous" in json.loads((RUNS / "Kitchen.ui.json").read_text())
|
||||
assert server.watch_once(True) is False # it has just ended
|
||||
assert "previous" not in json.loads((RUNS / "Kitchen.ui.json").read_text())
|
||||
notes = wait_for_tool("notify-send")
|
||||
assert notes and "Kitchen" in notes[-1][-1]
|
||||
|
||||
|
||||
def test_after_a_failed_job_the_watcher_says_so():
|
||||
os.environ["FAKE_PIPELINE"] = "fail"
|
||||
convert()
|
||||
server.watch_once(True)
|
||||
assert wait_for_tool("notify-send")
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Recordings on the PC and on the Frame: renaming, the Frame's list, download, copying scenes over."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from conftest import REC, RUNS, recording, server, wait_for_tool
|
||||
import synth
|
||||
|
||||
VIDEOS = server.FRAME_VIDEOS
|
||||
SCENES = server.FRAME_SCENES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- renaming a recording on the PC
|
||||
|
||||
def test_renaming_a_recording_keeps_date_check_result_and_thumbnail(api):
|
||||
rec = recording("2026-10-12_16-40-00_stereo.mp4")
|
||||
server.CACHE.mkdir(parents=True, exist_ok=True)
|
||||
check = server.check_file(rec)
|
||||
check.write_text(json.dumps({"level": "warn"}))
|
||||
(server.CACHE / "2026-10-12_16-40-00_stereo.thumb").write_bytes(b"thumb")
|
||||
status, r = api.post("/api/rename", {"name": rec.name, "new": "Kitchen / evening"})
|
||||
assert status == 200 and r["name"] == "Kitchen - evening.mp4"
|
||||
new = REC / "Kitchen - evening.mp4"
|
||||
assert new.exists() and not rec.exists()
|
||||
f = next(x for x in server.local_files() if x["name"] == new.name)
|
||||
assert (f["date"], f["label"], f["orig"]) == ("2026-10-12T16:40", "Kitchen - evening", rec.name)
|
||||
assert f["check"] == "warn" # the check result moved along
|
||||
assert (server.CACHE / "Kitchen - evening.thumb").read_bytes() == b"thumb"
|
||||
|
||||
|
||||
def test_runs_follow_a_renamed_recording(api):
|
||||
rec = recording()
|
||||
synth.make_room(RUNS, "Kitchen", "A", recording=rec.name) # a finished scene made from it
|
||||
api.post("/api/rename", {"name": rec.name, "new": "Kitchen take 1"})
|
||||
meta = json.loads((RUNS / "Kitchen.ui.json").read_text())
|
||||
assert meta["recording"] == "Kitchen take 1.mp4" and meta["recording_renamed"] is True
|
||||
assert server.no_resume("Kitchen") == "renamed"
|
||||
|
||||
|
||||
def test_a_recording_with_an_unfinished_run_cannot_be_renamed(api):
|
||||
rec = recording()
|
||||
(RUNS / "Kitchen").mkdir() # interrupted, no scene.sog
|
||||
(RUNS / "Kitchen.ui.json").write_text(json.dumps({"name": "Kitchen", "recording": rec.name}))
|
||||
status, r = api.post("/api/rename", {"name": rec.name, "new": "Other"})
|
||||
assert status == 400 and r["error"]["code"] == "err.recording_unfinished" # the pipeline could not resume it
|
||||
assert rec.exists()
|
||||
|
||||
|
||||
def test_renaming_a_recording_to_a_taken_name_is_refused(api):
|
||||
a, b = recording("2026-10-12_16-40-00_stereo.mp4"), recording("2026-10-12_16-43-00_stereo.mp4")
|
||||
status, r = api.post("/api/rename", {"name": a.name, "new": b.stem})
|
||||
assert status == 400 and r["error"]["code"] == "err.name_taken"
|
||||
assert api.post("/api/rename", {"name": "../../etc/passwd", "new": "x"})[0] == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the Frame
|
||||
|
||||
def put_on_frame(home, name="2026-10-14_10-00-00_stereo.mp4", data=b"recording" * 1000):
|
||||
(home / VIDEOS / name).write_bytes(data)
|
||||
(home / VIDEOS / "thumbnails" / name.replace("_stereo.mp4", ".jpg")).write_bytes(b"jpegdata")
|
||||
return name, data
|
||||
|
||||
|
||||
def test_the_frames_recordings_are_listed(api, frame):
|
||||
name, data = put_on_frame(frame)
|
||||
(frame / SCENES / "20_Kitchen").mkdir()
|
||||
status, r = api.get("/api/recordings?refresh=1")
|
||||
assert status == 200 and r["frame_online"]
|
||||
f = r["frame"][0]
|
||||
assert (f["name"], f["size"], f["date"], f["local"]) == (name, len(data), "2026-10-14T10:00", None)
|
||||
assert server.frame_listing()["scenes"] == ["20_Kitchen"]
|
||||
|
||||
|
||||
def test_a_frame_that_is_off(api, frame):
|
||||
os.environ["FAKE_FRAME_OFF"] = "1"
|
||||
r = api.get("/api/recordings?refresh=1")[1]
|
||||
assert r["frame_configured"] and r["frame_online"] is False and r["frame"] is None
|
||||
|
||||
|
||||
def test_the_frames_preview_image(api, frame):
|
||||
name, _ = put_on_frame(frame)
|
||||
status, body = api.get(f"/api/thumb?source=frame&name={name}")
|
||||
assert status == 200 and body == b"jpegdata"
|
||||
|
||||
|
||||
def test_downloading_a_recording(api, frame):
|
||||
name, data = put_on_frame(frame)
|
||||
server.frame_listing(force=True)
|
||||
assert api.post("/api/download", {"name": name})[0] == 200
|
||||
end = time.time() + 20
|
||||
while not (server.download or {}).get("finished") and time.time() < end:
|
||||
time.sleep(0.1)
|
||||
assert server.download["error"] is None
|
||||
assert (REC / name).read_bytes() == data and not (REC / (name + ".part")).exists()
|
||||
status, r = api.post("/api/download", {"name": name}) # never overwrites
|
||||
assert status == 400 and r["error"]["code"] == "err.name_taken"
|
||||
assert api.post("/api/download", {"name": "not-there.mp4"})[1]["error"]["code"] == "err.not_on_frame"
|
||||
|
||||
|
||||
def wait_upload(slug):
|
||||
end = time.time() + 120
|
||||
while time.time() < end:
|
||||
u = server.upload_status(slug)
|
||||
if u and not u["running"]:
|
||||
return u
|
||||
time.sleep(0.2)
|
||||
raise TimeoutError
|
||||
|
||||
|
||||
def test_copying_a_scene_to_the_frame(api, frame):
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
assert api.post("/api/upload", {"slug": "Kitchen"})[0] == 200
|
||||
u = wait_upload("Kitchen")
|
||||
assert u["ok"], u.get("error")
|
||||
folder = frame / SCENES / "20_Kitchen"
|
||||
assert sorted(p.name for p in folder.iterdir()) == [
|
||||
"kitchen.gallery.png", "kitchen.sceneinfo.png", "kitchen.sog", "kitchen.xrSceneTransform.json"]
|
||||
assert (frame / "Documents" / "GaussianSplats" / "20_Kitchen" / "kitchen.sog").exists() # backup copy
|
||||
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["frame_folder"] == "20_Kitchen"
|
||||
assert wait_for_tool("notify-send") # sent in the background
|
||||
assert {s["slug"]: s["on_frame"] for s in server.scenes()}["Kitchen"] is True
|
||||
|
||||
|
||||
def test_a_renamed_scene_stays_in_its_frame_folder(api, frame):
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
api.post("/api/upload", {"slug": "Kitchen"})
|
||||
wait_upload("Kitchen")
|
||||
api.post("/api/rename", {"slug": "Kitchen", "new": "Küche"})
|
||||
assert api.post("/api/upload", {"slug": "Kueche"})[0] == 200
|
||||
assert wait_upload("Kueche")["ok"]
|
||||
scenes = sorted(p.name for p in (frame / SCENES).iterdir())
|
||||
assert scenes == ["20_Kitchen"] # no second copy
|
||||
assert sorted(p.name for p in (frame / SCENES / "20_Kitchen").iterdir()) == [
|
||||
"kueche.gallery.png", "kueche.sceneinfo.png", "kueche.sog", "kueche.xrSceneTransform.json"]
|
||||
|
||||
|
||||
def test_a_broken_download_leaves_nothing_behind(api, frame):
|
||||
name, data = put_on_frame(frame)
|
||||
server.frame_listing(force=True)
|
||||
os.environ["FAKE_FRAME_CUT"] = "1" # the connection breaks halfway
|
||||
assert api.post("/api/download", {"name": name})[0] == 200
|
||||
end = time.time() + 20
|
||||
while not (server.download or {}).get("finished") and time.time() < end:
|
||||
time.sleep(0.1)
|
||||
assert server.download["error"]
|
||||
assert not (REC / name).exists() and not (REC / (name + ".part")).exists()
|
||||
|
||||
|
||||
def test_a_scene_renamed_before_its_frame_folder_was_remembered(api, frame):
|
||||
"""Copied to the Frame by an older version (no frame_folder in its settings): renaming finds the
|
||||
folder on the Frame, so copying again does not create a second one."""
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
(frame / SCENES / "20_Kitchen").mkdir()
|
||||
(frame / SCENES / "20_Kitchen" / "kitchen.sog").write_bytes(b"old")
|
||||
server.frame_listing(force=True)
|
||||
assert api.post("/api/rename", {"slug": "Kitchen", "new": "Küche"})[0] == 200
|
||||
assert json.loads((RUNS / "Kueche.ui.json").read_text())["frame_folder"] == "20_Kitchen"
|
||||
api.post("/api/upload", {"slug": "Kueche"})
|
||||
assert wait_upload("Kueche")["ok"]
|
||||
assert sorted(p.name for p in (frame / SCENES).iterdir()) == ["20_Kitchen"]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Finished scenes: renaming, cleaning up, deleting, and runs that did not finish."""
|
||||
import json
|
||||
|
||||
from conftest import RUNS, TRASH, server
|
||||
import synth
|
||||
|
||||
|
||||
def scene_names():
|
||||
return {s["slug"]: s["name"] for s in server.scenes()}
|
||||
|
||||
|
||||
def test_finished_scenes_are_listed(api):
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
status, scenes = api.get("/api/scenes")
|
||||
assert status == 200
|
||||
assert [(s["slug"], s["name"], s["ply"]) for s in scenes] == [("Kitchen", "Kitchen", True)]
|
||||
assert scenes[0]["gaussians"] > 1000
|
||||
|
||||
|
||||
def test_renaming_moves_folder_files_and_log(api):
|
||||
run = synth.make_room(RUNS, "Aufnahme-12.10.-16-40", "A", name="Aufnahme 12.10.")
|
||||
(run / "aufnahme-12.10.-16-40.sog").write_bytes(b"x") # gallery files are named after the folder
|
||||
(run / "aufnahme-12.10.-16-40.gallery.png").write_bytes(b"x")
|
||||
status, r = api.post("/api/rename", {"slug": "Aufnahme-12.10.-16-40", "new": "Büro 12.10."})
|
||||
assert status == 200, r
|
||||
new = RUNS / "Buero-12.10."
|
||||
assert new.is_dir() and not run.exists()
|
||||
assert (new / "buero-12.10..sog").exists() and (new / "buero-12.10..gallery.png").exists()
|
||||
assert (RUNS / "Buero-12.10..log").exists() and not (RUNS / "Aufnahme-12.10.-16-40.log").exists()
|
||||
meta = json.loads((RUNS / "Buero-12.10..ui.json").read_text())
|
||||
assert meta["name"] == "Büro 12.10." and meta["folder_renamed"] is True
|
||||
assert server.no_resume("Buero-12.10.") == "renamed" # the pipeline remembers the old path
|
||||
|
||||
|
||||
def test_renaming_to_a_taken_name_is_refused(api):
|
||||
synth.make_room(RUNS, "Kitchen", "A")
|
||||
synth.make_room(RUNS, "Office", "B")
|
||||
status, r = api.post("/api/rename", {"slug": "Office", "new": "Kitchen"})
|
||||
assert status == 400 and r["error"]["code"] == "err.scene_name_taken"
|
||||
assert (RUNS / "Office").is_dir()
|
||||
|
||||
|
||||
def test_cleaning_up_removes_only_what_it_says(api):
|
||||
run = synth.make_room(RUNS, "Kitchen", "A")
|
||||
for rel in ("spirula/training/step-1.ckpt", "dataset/.spirula-sfm/x", "dataset/images/cam0/0000.jpg"):
|
||||
(run / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(run / rel).write_bytes(b"x" * 1000)
|
||||
assert api.post("/api/clean", {"slug": "Kitchen", "what": "temp"})[0] == 200
|
||||
assert not (run / "spirula/training").exists() and not (run / "dataset/.spirula-sfm").exists()
|
||||
assert (run / "dataset/images/cam0/0000.jpg").exists() # training images stay
|
||||
assert (run / "spirula/scene.ply").exists() and (run / "dataset/sparse/0/images.bin").exists()
|
||||
assert server.no_resume("Kitchen") is None
|
||||
assert api.post("/api/clean", {"slug": "Kitchen", "what": "images"})[0] == 200
|
||||
assert not (run / "dataset/images").exists()
|
||||
assert server.no_resume("Kitchen") == "images" # only a fresh start from now on
|
||||
assert api.post("/api/clean", {"slug": "Kitchen", "what": "everything"})[0] == 400
|
||||
|
||||
|
||||
def test_deleting_moves_to_the_trash(api):
|
||||
synth.make_room(RUNS, "Kitchen", "A")
|
||||
assert api.post("/api/delete", {"slug": "Kitchen"})[0] == 200
|
||||
assert "Kitchen" not in scene_names()
|
||||
assert {p.name for p in TRASH.iterdir()} >= {"Kitchen", "Kitchen.log", "Kitchen.ui.json"}
|
||||
|
||||
|
||||
def test_unfinished_runs_resume_or_start_over(api):
|
||||
# interrupted in the training: camera positions done -> it can continue
|
||||
(RUNS / "Hallway" / "dataset").mkdir(parents=True)
|
||||
(RUNS / "Hallway" / "dataset" / "spirula-dataset.json").write_text('{"status": "complete"}')
|
||||
(RUNS / "Hallway.log").write_text("Running native Spirula SfM\nstep 13801/30000 ( 46%) splats 2000000 "
|
||||
"[elapsed 00:10:00.0 | ETA 00:12:00.0] rgb_loss=0.1 ssim=0.7 psnr=25\n")
|
||||
rec = "2026-10-12_16-43-00_stereo.mp4"
|
||||
(RUNS / "Hallway.ui.json").write_text(json.dumps({"name": "Hallway", "recording": rec}))
|
||||
(server.REC / rec).write_bytes(b"x")
|
||||
# stopped while extracting images -> only from the start
|
||||
(RUNS / "Broken").mkdir()
|
||||
(RUNS / "Broken.log").write_text("Extraction progress: decoded 4% / saved 44 of 1000\n")
|
||||
(RUNS / "Broken.ui.json").write_text(json.dumps({"name": "Broken", "recording": rec}))
|
||||
(RUNS / "old-test.log").write_text("something\n") # only a log file left
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen") # finished: not in this list
|
||||
|
||||
status, runs = api.get("/api/unfinished")
|
||||
assert status == 200
|
||||
by = {r["slug"]: r for r in runs}
|
||||
assert set(by) == {"Hallway", "Broken", "old-test"}
|
||||
assert (by["Hallway"]["phase"], by["Hallway"]["pct"], by["Hallway"]["resumable"]) == (2, 46, True)
|
||||
assert (by["Broken"]["phase"], by["Broken"]["resumable"], by["Broken"]["restartable"]) == (0, False, True)
|
||||
assert by["old-test"]["phase"] is None and not by["old-test"]["restartable"]
|
||||
assert api.post("/api/resume", {"slug": "Broken"})[1]["error"]["code"] == "err.not_resumable"
|
||||
assert api.post("/api/delete", {"slug": "old-test"})[0] == 200
|
||||
assert not (RUNS / "old-test.log").exists()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Who may talk to the server: only this PC (or the home network in LAN mode), never other websites."""
|
||||
import pytest
|
||||
|
||||
from conftest import RUNS, SHUTDOWNS, server
|
||||
import synth
|
||||
|
||||
|
||||
def test_page_and_api_answer_locally(api):
|
||||
assert api.get("/")[0] == 200
|
||||
status, ping = api.get("/api/ping")
|
||||
assert status == 200 and ping["app"] == "splat-workshop" and ping["version"] == server.VERSION
|
||||
|
||||
|
||||
@pytest.mark.parametrize("site", ["cross-site", "same-site"])
|
||||
def test_other_websites_are_refused(api, site):
|
||||
# e.g. <img src="http://127.0.0.1:8771/api/floorplan?..."> on any website, or another port on localhost
|
||||
assert api.get("/api/scenes", {"Sec-Fetch-Site": site})[0] == 403
|
||||
assert api.post("/api/cancel", {}, {"Sec-Fetch-Site": site})[0] == 403
|
||||
|
||||
|
||||
def test_own_page_and_typed_address_work(api):
|
||||
assert api.get("/api/scenes", {"Sec-Fetch-Site": "same-origin"})[0] == 200
|
||||
assert api.get("/api/scenes", {"Sec-Fetch-Site": "none"})[0] == 200
|
||||
|
||||
|
||||
def test_a_link_may_open_the_page_but_not_the_api(api):
|
||||
nav = {"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "navigate"}
|
||||
assert api.get("/", nav)[0] == 200
|
||||
assert api.get("/api/scenes", nav)[0] == 403
|
||||
|
||||
|
||||
def test_post_from_another_origin_is_refused(api):
|
||||
assert api.post("/api/cancel", {}, {"Origin": "http://evil.example"})[0] == 403
|
||||
|
||||
|
||||
def test_dns_rebinding_is_refused(api):
|
||||
# a foreign host name that resolves to 127.0.0.1
|
||||
assert api.get("/api/scenes", {"Host": "evil.example:8771"})[0] == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lan,client,ok", [
|
||||
(False, "127.0.0.1", True), (False, "192.168.1.20", False),
|
||||
(True, "192.168.1.20", True), (True, "10.8.0.2", True),
|
||||
(True, "100.100.1.1", False), # carrier-grade NAT / Tailscale: not a home network
|
||||
(True, "84.150.3.3", False), # the internet, e.g. through a port forwarding
|
||||
])
|
||||
def test_lan_mode_checks_the_real_client_address(monkeypatch, lan, client, ok):
|
||||
monkeypatch.setattr(server.CFG, "lan", lan)
|
||||
|
||||
class H(server.Handler):
|
||||
def __init__(self): # no socket: only what _host_ok looks at
|
||||
self.client_address = (client, 50000)
|
||||
self.headers = {"Host": "192.168.1.5:8771" if lan else "127.0.0.1:8771"}
|
||||
self.command, self.path = "GET", "/api/scenes"
|
||||
assert H()._host_ok() is ok
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,headers", [
|
||||
(b"[1, 2]", {}), # a list instead of an object
|
||||
(b'{"name": 5, "new": "x"}', {}), # a number instead of text
|
||||
(b'{"slug": {"a": 1}}', {}), # nested objects
|
||||
(b"{not json", {}),
|
||||
(b"", {"Content-Length": "-5"}),
|
||||
(b"", {"Content-Length": str(10 ** 9)}),
|
||||
(b'{"slug": "x", "full": {"a": 1}}', {}), # nested value in a field that is not text
|
||||
])
|
||||
def test_invalid_requests_get_400_not_a_crash(api, body, headers):
|
||||
assert api.post("/api/rename", body, headers)[0] == 400
|
||||
assert api.get("/api/ping")[0] == 200 # still alive
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def leak():
|
||||
"""Something that looks like a scene one level above the runs folder – it must never be reachable."""
|
||||
above = RUNS.parent
|
||||
(above / "spirula").mkdir(exist_ok=True)
|
||||
(above / "spirula" / "scene.ply").write_bytes(b"ply\n")
|
||||
(above / "leak.gallery.png").write_bytes(b"secret")
|
||||
(above / "scene.sog").write_bytes(b"secret")
|
||||
yield
|
||||
for f in ("leak.gallery.png", "scene.sog"):
|
||||
(above / f).unlink(missing_ok=True)
|
||||
(above / "spirula" / "scene.ply").unlink(missing_ok=True)
|
||||
(above / "spirula").rmdir()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", ["..", "../runs", "/etc", ".previous", "a/../../b", "."])
|
||||
def test_no_paths_outside_the_runs_folder(api, leak, slug):
|
||||
synth.make_room(RUNS, "Room", "A")
|
||||
assert server.scene_dir(slug) is None
|
||||
status, body = api.get(f"/api/preview?slug={slug}")
|
||||
assert status == 404 and body != b"secret"
|
||||
assert api.get(f"/api/floorplan?slugs={slug}")[0] == 400
|
||||
for path, body in (("/api/delete", {"slug": slug}), ("/api/clean", {"slug": slug, "what": "temp"}),
|
||||
("/api/rename", {"slug": slug, "new": "x"}), ("/api/upload", {"slug": slug}),
|
||||
("/api/combine", {"slug": slug})):
|
||||
assert api.post(path, body)[0] == 400, path
|
||||
assert (RUNS / "Room" / "scene.sog").exists() and (RUNS.parent / "leak.gallery.png").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,body", [
|
||||
("/api/open", {"slug": "Room", "what": "folder"}), ("/api/play", {"name": "x.mp4"}),
|
||||
("/api/pick", {}), ("/api/shutdown", {}),
|
||||
])
|
||||
def test_actions_on_the_screen_only_from_this_pc(api, monkeypatch, path, body):
|
||||
# video player, file dialog, folder, Spirula and quitting: not from another device in the LAN
|
||||
monkeypatch.setattr(server.Handler, "_from_this_pc", lambda self: False)
|
||||
started = []
|
||||
monkeypatch.setattr(server.subprocess, "Popen", lambda *a, **k: started.append(a))
|
||||
monkeypatch.setattr(server, "pick_file", lambda: started.append("file dialog"))
|
||||
synth.make_room(RUNS, "Room", "A")
|
||||
status, r = api.post(path, body)
|
||||
assert status == 403 and r["error"]["code"] == "err.pc_only"
|
||||
assert started == [] and SHUTDOWNS == [] # and nothing happened
|
||||
|
||||
|
||||
def test_from_this_pc_means_loopback():
|
||||
class Remote(server.Handler):
|
||||
def __init__(self):
|
||||
self.client_address = ("192.168.1.20", 50000)
|
||||
|
||||
class Local(server.Handler):
|
||||
def __init__(self):
|
||||
self.client_address = ("127.0.0.1", 50000)
|
||||
assert not Remote()._from_this_pc() and Local()._from_this_pc()
|
||||
|
||||
|
||||
ENDPOINTS = ["/api/start", "/api/rename", "/api/clean", "/api/delete", "/api/upload", "/api/download", "/api/check",
|
||||
"/api/group", "/api/group/dismiss", "/api/combine", "/api/resume"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ENDPOINTS)
|
||||
@pytest.mark.parametrize("field", ["name", "new", "slug", "path", "what", "members", "key", "align", "quality"])
|
||||
def test_wrongly_typed_fields_get_400_everywhere(api, path, field):
|
||||
for bad in (5, ["a"], True):
|
||||
assert api.post(path, {field: bad})[0] == 400, (path, field, bad)
|
||||
|
||||
|
||||
def test_unknown_thumbnail_names_leave_no_cache_files(api):
|
||||
status, _ = api.get("/api/thumb?source=frame&name=whatever.mp4")
|
||||
assert status == 404
|
||||
assert not list(server.CACHE.glob("whatever*"))
|
||||
|
||||
|
||||
def test_nested_values_are_refused_in_any_field(api):
|
||||
# a request that would work, apart from one nested value in a field that is not text
|
||||
synth.make_room(RUNS, "Room", "A")
|
||||
assert api.post("/api/rename", {"slug": "Room", "new": "Other", "update_frame": {"a": 1}})[0] == 400
|
||||
assert (RUNS / "Room").is_dir() and not (RUNS / "Other").exists()
|
||||
Reference in new issue
Block a user