Compare commits

...
60 Commits
Author SHA1 Message Date
thunderheronightandClaude Opus 5.5 20b1262fa9 0.4.1: clearer setup – config typos named, full path for splat-transform, PyAV 19 spotted
From issues #1 and #2:
- Unknown settings in config.toml (e.g. splat-transform instead of splat_transform) show up in the
  setup hints and in install.sh, with the right spelling.
- install.sh writes the full path of splat-transform into a new config or prints the line for an
  existing one: started from the desktop, the app does not see the shell's PATH.
- The setup guide's npm --prefix instructions match the path they tell you to configure.
- A pipeline not pinned below PyAV 19 is spotted before a run; a run failing on it says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:06:01 +02:00
thunderheronightandClaude Opus 5.5 080e9d0b97 0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:20:17 +02:00
thunderheronightandClaude Opus 5.5 536340e468 0.4: tidier action bars – do, look, change; unfolding lists on their own line; one versions button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:14:19 +02:00
thunderheronightandClaude Opus 5.5 6a62720554 0.4: versions by purpose, unfolding under their scene, openable in Spirula
- Dialog: for the Frame (one size, with measured fps) or for the PC (all splats); clean-up; optionally
  the same without; a preview of what will be made and what it replaces; one version per click.
- Names carry the clean-up strengths; As template takes a version's settings into the form.
- A cleaned PC version keeps its ply; /api/open opens a version in Spirula Studio.
- Versions unfold under their scene or tour, with Spirula, Frame, template and delete.
- The PC sizes 3/5/8 M are gone; measured frame rates updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:37:36 +02:00
thunderheronightandClaude Opus 5.5 7e1b7fecf8 0.4: changelog and READMEs for cleaning up versions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:21:14 +02:00
thunderheronightandClaude Opus 5.5 addb45e2c2 0.4: free space from the cameras' lines of sight – experimental, not wired in
A splat clearly in front of what an image saw (its SfM points; optionally the solid splats where SfM saw
nothing) sat in the camera's line of sight: free space. On a real living room only 0.4 % were found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:13:17 +02:00
thunderheronightandClaude Opus 5.5 b44e4de901 0.4: clean-up sliders in the versions dialog, tests for clean.py and cleaned versions
- clean.py: the ceiling is the highest strong line where walls meet it (a cupboard top lies lower).
- The API takes the clean-up as a flat string (ceiling=0.5,haze=0.3), like every other field.
- tests/test_clean.py: a synthetic living room with thin ceiling, veil, cupboard top, bird cage,
  haze, a hole in the floor and things beyond the walls – each told apart by its colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:00:23 +02:00
thunderheronightandClaude Opus 5.5 fca8e81422 WIP 0.4: clean up a scene (ceiling, floor, haze, outside) as part of its versions
scripts/clean.py and an optional clean-up field for versions.sh and /api/versions. No interface and no
tests yet; tried on a real living room (the ceiling as liked in the Studio; haze now by the smallest axis,
so the bars of a bird cage are no haze any more – not checked yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:00:23 +02:00
thunderheronightandClaude Opus 5.5 d34c68a553 CLAUDE.md: release checklist (notes keep the series' highlights), tests, scripts, file systems
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:58:03 +02:00
thunderheronightandClaude Opus 5.5 2ce2c5696b 0.3.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:04:24 +02:00
thunderheronightandClaude Opus 5.5 fccaf4afc6 Folder names without a trailing dot (NTFS refuses them)
Found moving the runs to an NTFS disk: the tour "Rundgang 12.10." (folder Rundgang-12.10.) was not copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:02:35 +02:00
thunderheronightandClaude Opus 5.5 3cf0e81aef Fixes from the review; release 0.3.0
- The watcher reports the next part (or putting the parts together) as running in the tick that
  starts it: no false standby notice between parts.
- Deleting an unfinished part deletes its piece of video in runs/.parts too.
- combine.sh keeps the floor plan's labels (plan.json).
- Tests: the chain through the watcher's tick (twice), one standby notice per chain, the own lock
  released before suspending, rooms of the same name, view-dependent colour matched, the preview plan
  of a home named by rooms; notifications are awaited instead of checked too early.
- 0.3.0: version, changelog, READMEs without the preview notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:41:49 +02:00
thunderheronightandClaude Opus 5.5 ae9655bd3d Standby when done; kept awake between jobs; renamed rooms get a new floor plan
- A checkbox on the running job: when everything is finished (parts, putting them together, copying),
  a minute's notice, then systemctl suspend; cancelling a job switches it off; a refused standby is
  reported. Our own lock is released first (it kept the PC up in a first try by hand).
- One sleep lock of the server spans the seconds between two jobs (parts of a long recording).
- A renamed room's floor plan and previews are drawn again with the new name.
- Rooms of the same name: "Kitchen (2)"; the combine dialog and job card speak of a room's recordings.
- README: filming tips for ceilings and light; 0.3 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:54:40 +02:00
thunderheronightandClaude Opus 5.5 58cc77d2f3 Rooms: no dark holes from recordings that had nothing there; one brightness for all recordings
A place only goes to a recording that really has something there (a quarter of the richest one's splat
opacity, over the place and its neighbours). Colours of all recordings are matched per channel from the
places they share (--colour match|keep, COLOUR for combine.sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:55:21 +02:00
thunderheronightandClaude Opus 5.5 de6c632267 Split a long video into parts that become one room
Each part gets up to 500 image pairs; parts are cut losslessly with the original time stamps, converted
one after the other and put together as a room. The original stays (asked once more before deleting).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:02:44 +02:00
thunderheronightandClaude Opus 5.5 05fd105480 README (en/de) for the 0.3.0 preview; German README catches up with 0.2.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:46:33 +02:00
thunderheronightandClaude Opus 5.5 5c35fbb15c Mark 0.3 as experimental
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:02:52 +02:00
thunderheronightandClaude Opus 5.5 0e223a18fb Rooms in the web interface: room groups, homes made of rooms, nested display
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:10:56 +02:00
thunderheronightandClaude Opus 5.5 b9b60fa5a0 Rooms made of several recordings: combine, floor plan and groups (core, no UI yet)
A group can be a room (several recordings of one room); any other group may hold scenes and rooms and
is put together from the recordings, each in its room. Recordings of one room no longer count as
walking in to each other, which also hid a real walk-in from another room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:06:23 +02:00
thunderheronightandClaude Opus 5.5 b5a9b5a0f1 Review fixes: group scale for floor plans, move waits for uploads, more tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:41:34 +02:00
thunderheronightandClaude Opus 5.5 a1f9df0d1f Delete a single recording on the Frame or on this PC (trash)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:35:53 +02:00
thunderheronightandClaude Opus 5.5 bc3345a5aa Fetch several recordings from the Frame at once (all or a selection, one after the other)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:30:21 +02:00
thunderheronightandClaude Opus 5.5 daf6a17241 Scenes go to Gaussian XR's personal scenes folder; 1 M splats recommended for the Frame
Documents/GaussianXR is left alone by Steam updates. Old copies in the app's own Scenes folder
move over when copied again, or all at once from the On the Frame tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:17:54 +02:00
thunderheronightandClaude Opus 5.5 e69961ad3e "On the Frame" tab; no backup copies on the Frame any more
The tab lists everything in Gaussian XR's gallery on the Frame with its preview image and what it
is (scene, tour, version, or not from Splat Workshop) and takes any of it off the Frame. Scenes are
copied straight into the gallery; copies left in Documents/GaussianSplats go along with their scene.
The tour dialog offers 0.75–2 M or an own size. 250 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:40:16 +02:00
thunderheronightandClaude Opus 5.5 dfaa6a4a1c Versions: take a version off the Frame (its gallery folder and backup copy, nothing else)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:32:10 +02:00
thunderheronightandClaude Opus 5.5 7e64f039d6 Versions: several sizes and scales of a scene or tour side by side, each copyable to the Frame
For the Frame 0.75 / 1 / 1.5 / 2 M, for the PC all / 3 / 5 / 8 M, plus an own size; with a scale
also walkable in the original's size. Every version is a complete scene in <run>/versions/<key>/
and gets its own scene folder on the Frame, so e.g. 0.75 M and 2 M can be compared in the gallery.
The tour dialog offers 0.75–2 M or an own size for its Frame version. Measured frame rates are
shown as information only. 14 new tests (240 in all).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:15:44 +02:00
thunderheronightandClaude Opus 5.5 fb1c0ff515 0.2.3: scale for models (e.g. a model railway), for scenes and groups
Floor plan, sizes, scale bar and measuring tool in the original's size; the floor plan measures
walls and floors in the model's scale. Optionally Gaussian XR's worldScale is set when copying to
the Frame (not tested on the Frame yet). The floor plan no longer fails without walls.
13 new tests (226 in all).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:44:10 +02:00
thunderheronightandClaude Opus 5.5 fd8cd8dbb3 Tests, second review round: no desktop actions or silent exits from broken code; last gaps
- Fakes for kdialog, zenity and xdg-open; every test catches shutdown/os._exit
- Broken download, frame folder found on rename, nested values in non-text fields,
  Start over from the cancelled runs, the watcher's notification and tidying up
  (watcher split into watch_once for that)
- Fake pipeline refuses to continue an unfinished extraction, like the real one
213 tests, every mutation of both review rounds is caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:22:52 +02:00
thunderheronightandClaude Opus 5.5 da00372c5a 0.2.2: tests hardened after an independent review; strict types for all text fields
206 tests instead of 57; every surviving mutation of the review is caught now. New fakes for
ffprobe, ssh/scp (the Frame as a folder) and notify-send; synthetic rooms with tracking noise,
scale error, turned splats, tracking turn and an adjustable walk-in. Fixed: wrongly typed
members/key/align/partition gave a 500 or were stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:52:56 +02:00
thunderheronightandClaude Opus 5.5 a8a6aa5188 0.2.1: automated tests and GitHub Actions; a cancelled job only ends with its last process
57 tests without GPU, recordings or Frame: fake pipeline, fake splat-transform and trash, and
synthetic rooms with a known error for putting rooms together. Found on the way: after cancelling,
the job counted as finished when systemd-inhibit ended, while splat.sh was still putting the
previous scene back – pid_alive now looks at the whole process group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:22:30 +02:00
thunderheronightandClaude Opus 5.5 49124eacc8 README: "No root, no jailbreak" badges and note, release badge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:03:28 +02:00
thunderheronightandClaude Opus 5.5 4f339b2150 README: screenshots of groups and tours (example data, placeholder art)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:58:52 +02:00
thunderheronightandClaude Opus 5.5 b49ecd4cc5 Clean up all scenes of a group at once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:53:24 +02:00
thunderheronightandClaude Opus 5.5 ca6471c9f1 Group dialog: offer only scenes that are in no other group (one group per scene)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:50:46 +02:00
thunderheronightandClaude Opus 5.5 5e7720d5a0 0.2.0: changelog, "New in 0.2" in the READMEs; fix the "+ Group" button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:48:17 +02:00
thunderheronightandClaude Opus 5.5 d2fbb3e81f Remove the combined floor plan button: plans of several scenes come from their group
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:43:34 +02:00
thunderheronightandClaude Opus 5.5 92b884c8a0 Groups of scenes and putting a tour together
- Scenes can be grouped (with a name); a group shows collapsed with its tour or expanded
  with its scenes. Scenes recorded right after each other are suggested as a group.
- "Put the tour together" (scripts/combine.sh + combine.py) as a background job: full version
  for the PC and a reduced one for the Frame, floor plan as preview, optional copy to the Frame.
- Corrections to choose: A Frame only, B all images (default), C + shift the overlaps,
  D + shift and turn; borders through doors (walk in/out ignored) or nearest camera.
  The group shows how well the overlaps match, and when its scenes changed since.
- Memory estimate before starting; the full version can be left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:38:04 +02:00
thunderheronightandClaude Opus 5.5 3628f71b9a Cancelled runs: list them, resume or start over, delete
Runs without a finished scene (and leftover logs) show up below the finished scenes with
how far they got and their size. Resume where the pipeline can continue (camera positions
complete), otherwise start over with the same settings; delete moves them to the trash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:38:36 +02:00
thunderheronightandClaude Opus 5.5 1cfbcc4e01 Renaming a scene also renames its folder, files and log
The scene keeps its folder on the Frame (frame_folder); an upload removes the files
under the old name there, so the folder holds one scene. A renamed folder can only be
started over, like a renamed recording. Renaming with the same name tidies up the folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:32:53 +02:00
thunderheronightandClaude Opus 5.5 e44a9a1eac Scene cards: broom for clean-up, trash can deletes the scene
Deleting asks first and moves the scene folder, log and settings to the desktop trash
(recoverable); a copy on the Frame is not touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:27:39 +02:00
thunderheronightandClaude Opus 5.5 5d7325cdf1 Floor plan: draw the top view, not its mirror image
The scenes use a Y-down frame; seen from above, x has to run right to left on screen.
Cached plans are drawn anew when floorplan.py changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:14:20 +02:00
thunderheronightandClaude Opus 5.5 a415b490e0 Start over: restore a hidden scene after a hard kill; keep it listed meanwhile
- The server puts a scene from runs/.previous back when splat.sh was killed before it could
  (SIGKILL after cancelling, power cut) and no run of that scene is going
- While a scene is computed again it stays in the list, marked, without actions
- No leftovers after success: empty .previous folder and the "previous" key are removed
- Scene cards knew too late that no Frame is configured (frame_configured from /api/ping)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:07:03 +02:00
thunderheronightandClaude Opus 5.5 1f44b29536 Review fixes: safer start-over, LAN and cross-site checks, robustness
- Start over keeps the finished scene until the new one is done and restores
  it (with its settings) if the run fails, is cancelled or never finishes
- LAN mode checks the client's real address, not only the Host header
- Requests from other websites (Sec-Fetch-Site) are refused, except opening the page
- Invalid requests get a 400 instead of a dropped connection; timeouts everywhere
- At most two floor plans drawn at once; no cache entries for unknown Frame names
- Server also checks the recording before starting; downloads never overwrite
- Metadata written atomically and under a lock
- Scripts run as one block, so updating them does not affect a running job
- Page: offline notice, no polling in background tabs, long names wrap,
  thumbnails with quotes in the name, dialog padding click, contrast

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:55:20 +02:00
thunderheronightandClaude Opus 5.5 2ff2f92c46 Renaming a recording: protect runs that use it
The pipeline stores the recording's path and refuses to resume with another one.
Renaming is blocked while a run of the recording is unfinished; finished runs
remember it, and the UI offers only a fresh start for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:37:25 +02:00
thunderheronightandClaude Opus 5.5 ca8e68e284 README: renaming and clean-up
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:34:13 +02:00
thunderheronightandClaude Opus 5.5 9ed3c5e581 Rename recordings and scenes; show disk usage per scene and clean up
- Recordings on this PC can be renamed. The original name is remembered, so the
  date, the match with the Frame listing, thumbnails and check results stay.
- Scenes can be renamed: new name plate for the gallery, optionally copied to the Frame.
- Each scene shows its disk usage. The clean-up dialog deletes intermediate data
  (training checkpoint, SfM working files) or the training images. Scene, floor
  plan and Spirula viewer keep working; after deleting the images a run can only
  be started over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:34:03 +02:00
thunderheronightandClaude Opus 5.5 ea8e8dca8e Resume: restart an interrupted training instead of failing
The pipeline only reuses completed stages and refuses an incomplete training
directory. Remove it, so images and camera positions are reused and only the
training runs again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:21:16 +02:00
thunderheronightandClaude Opus 5.5 3c9bdff3c7 Default the target to "PC only" while the Frame is not reachable
Follows the Frame status until the user picks a target themselves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:20:05 +02:00
thunderheronightandClaude Opus 5.5 b63c644096 Setup guide: read the Spirula revision from the pipeline README instead of hard-coding it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:52:51 +02:00
thunderheronightandClaude Opus 5.5 a9a7c4e22f Add a step-by-step guide for setting up the Arcturus pipeline and Spirula
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:46:26 +02:00
thunderheronightandClaude Opus 5.5 363eee0afc Keep the PC from suspending while converting, copying or downloading
Jobs run under systemd-inhibit --what=sleep; KDE and GNOME respect the lock.
The lock belongs to the job process, so it ends with the job even if the server stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:26:25 +02:00
thunderheronightandClaude Opus 5.5 4cb73f70ce Configurable video player for "Open in video player"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:00:20 +02:00
thunderheronightandClaude Opus 5.5 a4c7cd43e8 Mark floor plans as experimental
Combining rooms has only been tested with two rooms so far.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:09:44 +02:00
thunderheronightandClaude Opus 5.5 e3c5b63bb3 README: floor plan screenshot (synthetic example home)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:08:07 +02:00
thunderheronightandClaude Opus 5.5 64aa770a3c Measuring: draw points even when the scale is unknown and say why
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:00:27 +02:00
thunderheronightandClaude Opus 5.5 ea1334b669 Floor plans: measuring tool
Click points on a floor plan to get segment lengths and the total distance,
with a live preview to the mouse pointer. The server sends the plan's scale
as X-Pixels-Per-Metre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:55:57 +02:00
thunderheronightandClaude Opus 5.5 b668c693c4 Floor plans: per scene and combined, in two styles
Scenes from one recording session share the Steam Frame's tracking frame,
so their floor plans line up without manual alignment. scripts/floorplan.py
draws a colour top view with walls and the camera path ("pretty") or a
glowing point density ("classic"), rotated so walls are axis-aligned.
The web UI gets a "Floor plan" button per scene and a "Combined floor plan"
dialog; plans are cached and can be saved as PNG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:53:07 +02:00
thunderheronightandClaude Opus 5.5 1ba541532e Docs: correct SSH setup on the Steam Frame, state platform support
SSH is enabled via Developer Mode and "Set User Password" (Valve docs),
not via passwd/systemctl. Linux only; macOS untested; Windows unsupported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:23:45 +02:00
thunderheronightandClaude Opus 5.5 53542016fe Describe Splat Workshop as a GUI for the Arcturus Vision pipeline
The reconstruction itself is done by the Arcturus Vision pipeline and
Spirula Studio. Also drop the people/person examples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:20:47 +02:00
thunderheronightandClaude Opus 5.5 1cfd3bd981 README: not just rooms, recording tips for objects and people, AI transparency note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:17:47 +02:00
46 changed files with 8724 additions and 190 deletions

No files matched your search

+14
View File
@@ -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
+1
View File
@@ -3,3 +3,4 @@ __pycache__/
.venv/
.splat-workshop/
*.part
.pytest_cache/
+160
View File
@@ -0,0 +1,160 @@
# Changelog
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.4.1] – 2026-10-15
Setup made clearer – thanks to the first two issues from outside (#1, #2).
### Fixed
- **A typo in the config is pointed out**: a setting Splat Workshop does not know – such as `splat-transform =` instead of `splat_transform =` – used to be ignored silently, so splat-transform stayed "not found" without a reason. Now the setup hints name it, with the right spelling. `install.sh` does too.
- **splat-transform on the shell's PATH only**: `install.sh` checked the PATH of the shell it runs in, but Splat Workshop, started from the desktop or the tray, does not read `.bashrc`/`.zshrc` – the installer said ✓, the app said "not found". Now `install.sh` writes the full path into a new config, or prints the line to add to an existing one; the hint in the app explains why and how.
- **The npm instructions** in the setup guide installed into a folder whose path did not match the one given for the config. Now: `npm install -g --prefix ~/arcturus/node @playcanvas/splat-transform` (no root needed) and `~/arcturus/node/bin/splat-transform`.
- **The Arcturus pipeline and PyAV 19** ("unexpected keyword argument 'metadata_errors'"): Splat Workshop now spots a pipeline that is not pinned to PyAV below 19 before you start, and names the cause and the fix when a conversion fails because of it – instead of only a traceback. It does not change the pipeline itself.
## [0.4.0] – 2026-10-15
### Added
- **Clean up a version** (experimental): in the versions dialog, four sliders from *off* to 100 % – the scene itself stays as it is, only the new version is cleaned up, so it can be compared side by side with one without (e.g. *all splats* for the PC).
- **Ceiling**: a white ceiling gives the reconstruction nothing to hold on to – it stays thin, with a dark veil under it. Takes out the veil (deeper with more strength; dark things within 50 cm of a wall, like a cupboard top or a bird cage, stay) and what lies above the ceiling, and puts in a closed ceiling in the colour of what there is of it. The ceiling's height is found where walls and ceiling meet. In a real living room: the veil of about 210,000 splats gone, the ceiling closed with 72,000.
- **Floor**: fills holes in the floor in the colour of the floor around them, and takes out what lies under it.
- **Haze**: takes out big, nearly transparent blobs floating in the room – measured by their *smallest* axis, so long thin things (the bars of a bird cage, cables) are no haze and stay.
- **Edges**: takes out what lies well outside the room – beyond the walls, above the ceiling, under the floor.
- Also on the command line: `scripts/clean.py`.
- **The versions dialog, reworked** – by purpose instead of by size: *for the Frame* (one size, each with its frame rate as measured: 0.75 M ≈ 36 fps, 1 M ≈ 35 fps and clearly sharper, 1.5 M ≈ 29 fps, 2 M ≈ 24 fps) or *for the PC* (all splats). One version per click, optionally the same *without cleaning up* next to it to compare. Before making, the dialog shows what it will make – and when that replaces an existing version.
- Versions are named by what was cleaned up and how strongly (*all splats · ceiling 50 % · haze 30 %*), so two tries never look alike.
- **As template** takes a version's settings into the form: change one slider and make the next.
- A cleaned version for the PC keeps its own file for **Spirula Studio** and opens there with one click.
- **Versions unfold under their scene or tour** (*▸ 3 versions*), with Spirula, copying to the Frame (with a warning above 2.5 M splats), template and delete.
- **Tidier action bars** on scenes and tours: what you do (put together, copy to the Frame) – what you look at (Spirula, floor plan, folder) – what you change (rename, scale, clean up, delete, as icons with tooltips); the unfolding lists (*▸ 3 recordings*, *▸ 1 version* or *+ Versions …*) on a line of their own. One versions button instead of two.
- The PC sizes 3, 5 and 8 M are gone – on the PC all splats is what counts; any other size goes through *own size*.
- `scripts/freespace.py` (experimental, command line only): takes out splats in space the cameras looked through, from the recordings' sparse models. On a real living room it found only 0.4 % – white walls and ceilings have too few points to tell – but looked a little better; kept for trying.
## [0.3.1] – 2026-10-15
### Fixed
- **Folder names without a dot at the end**: a name like "Tour 12.10." gave the folder `Tour-12.10.`, which Windows file systems (NTFS, e.g. a second disk the runs are moved to) refuse – the copy silently lacked that tour. Now it is `Tour-12.10`. Existing folders keep their names.
## [0.3.0] – 2026-10-15
### Added
- **Rooms made of several recordings**: mark a group as *one room made of several recordings*, e.g. two short rounds through the living room. When it is put together, every part of the room comes from the recording that saw it best. Each recording still gets up to 500 image pairs, so several short recordings give a room far more detail than one long one.
- **Split a long video**: for a recording longer than its quality preset can use (500 image pairs – 100 s at *High*), Splat Workshop offers *Split the video* or *Convert in one piece*, each with its pros and cons. Split, it suggests as few parts as give the full rate of image pairs, all equally long (a 4-minute recording at *High*: 3 parts of 1:24 with 420 pairs each instead of 2 pairs per second), converts them one after the other and puts them together into one room. The parts are cut losslessly with the original time stamps (the pipeline matches images and poses by time); the original recording is never changed and is needed to cut again, so deleting it asks once more.
- **A home made of rooms**: a group may hold such rooms next to single scenes. Putting it together always starts from the original recordings in one go, each in its room: within a room every part from the best recording, between rooms the borders run through the doors as before. Nothing is reduced twice. The floor plan names and measures rooms instead of recordings, and the scale of a home reaches its rooms and their recordings.
- **Standby when done**: a checkbox on the running job. When everything is finished – also the following parts of a long recording, putting them together and copying to the Frame – the PC goes to standby after a minute's notice (a notification says so; untick to stop it). Cancelling a job switches it off: someone is there. If the standby is refused (e.g. another program keeps the PC awake), a notification says why.
- **One brightness for all recordings**: every training settles on its own brightness, so the same wall could come out a third darker in one recording than in the next. Putting them together now brings all recordings to one brightness per colour channel, from the places they share (`COLOUR=keep` for combine.sh leaves them as they are). The factors are in combine.json.
### Fixed
- **Dark holes where a recording had nothing**: a place went to the recording whose cameras looked at it from close by, even when its training had left next to nothing there (seen from behind furniture, too dark …). Now a place only goes to a recording that has at least a quarter of what the richest one has there (compared over the place and its neighbours). In a real living room of three parts, the places left thin dropped from 8.5 % to 1.9 %.
- **Kept awake between jobs**: every job keeps the PC awake while it runs, but between two of them (the parts of a long recording, then putting them together) there were a few seconds without – enough for the desktop to suspend a PC that has been idle for hours. Splat Workshop now holds one lock of its own for all of it, until a little after the last job.
- **A renamed room** gets its floor plan and preview images drawn again with the new name (before: the old name until it was put together again).
- Two rooms of the same name in a home are told apart as "Kitchen" and "Kitchen (2)" (was "Kitchen ·").
- The dialog for putting a room together explains rooms, not tours.
- A part of a long recording that failed or was cancelled keeps its piece of video for resuming; deleting the unfinished run now deletes that piece too (it is as big as that stretch of the recording and was in no list).
- Recordings of the same room no longer count as "walking in" to each other. Before, two overlapping recordings could hide the real walk-in of another room, which then claimed part of the room.
## [0.2.3] – 2026-10-14
### Added
- **Scale for models**, e.g. a model railway: set Z, N, TT, H0, S, 0, G or your own scale for a scene or a whole group (optionally passed on to its scenes). Floor plan, room sizes, the scale bar and the measuring tool then show the size of the original – a 10 cm H0 model reads as 8.7 m. The floor plan also looks for walls and floors in the model's scale, so a model is drawn like a real building.
- **Walk through a model in its original size** in Gaussian XR (option): Splat Workshop sets Gaussian XR's `worldScale` when copying a scene or tour to the Frame – the scene files on the PC stay unchanged. *Not tested on the Frame yet.*
- **Versions** of a scene or tour, several side by side: fewer splats for the Frame (0.75 / 1 / 1.5 / 2 M), all or a limit for the PC (3 / 5 / 8 M), or an own size – each also in the original's size for models. Every version gets its own place in the Frame's gallery (e.g. "Flat · 1 M" next to "Flat · 2 M"), so you can find out yourself what runs smoothly. Frame rates measured on a Steam Frame are shown for information.
- **Fetch several recordings from the Frame at once**: *Fetch all* (everything not on this PC yet) or tick recordings and *Fetch selection*. They come one after the other, with one progress bar for all and *Cancel*; recordings ticked while fetching join the queue. A notification at the end says when the Frame can be switched off; one that fails does not stop the others.
- **Delete a recording**, one at a time: on the Frame (for good – with a clear warning if it is not on this PC yet) or on this PC (to the trash). A recording that is being converted, checked or fetched, or that an unfinished scene still needs, stays.
- **"On the Frame" tab** under the finished scenes: everything in Gaussian XR's gallery on the Frame with its preview image – scenes, tours, versions and Gaussian XR's own examples – and *Off the Frame* to delete any of them there (the PC is not touched).
### Changed
- **Scenes go to Gaussian XR's folder for personal scenes** (`Documents/GaussianXR` on the Frame) instead of the app's own `Scenes` folder, which a Steam update or "verify files" can reset. They appear in the gallery after Gaussian XR's examples. Scenes copied by older versions move over when copied again, or all at once with *Move to own scenes* in the *On the Frame* tab; numbers of new folders count both places.
- **No backup copies on the Frame any more**: everything is on the PC, and *Copy again* restores a scene. Copies left in `Documents/GaussianSplats` by older versions are removed together with their scene.
- **1 M splats is the recommended size for the Frame** (preselected for versions and tours, also the default of `combine.sh`, was 3.5 M). Measured on a Steam Frame with one tour: 0.75 M 36 fps, 1 M 35 fps, 1.5 M 29 fps, 2 M 24 fps, 5 M 14 fps – 1 M looked clearly sharper than 0.75 M, 1.5 M a little sharper but already slightly unsteady. All sizes stay selectable.
- Putting a tour together offers 0.75 to 2 M splats or an own size for its Frame version (was 2 / 3.5 / 5 M).
- Tests extended after an independent review with mutation testing (499 including the type checks of every request field): scale, versions, fetching several recordings, deleting recordings, the *On the Frame* tab and the new folder, and what is locked while a scene is computed, checked, fetched or copied.
### Fixed
- The floor plan no longer fails for scenes in which it finds no walls (e.g. a model without its scale).
- The floor plan of a group uses the group's scale, also when it was not passed on to its scenes. Scenes in different scales without one for the group give a clear message instead of a plan in the wrong size.
- *Move to own scenes* waits until no scene is being copied to the Frame.
## [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.**
### Added
- **Groups**: put scenes together in a named group, e.g. all rooms of a flat. A group shows collapsed (preview, size, tour) or expanded with its scenes. Scenes recorded right after each other are suggested as a group.
- **Tours**: *Put the tour together* turns the rooms of one recording session into one scene – a **full version** for Gaussian XR on the PC and Spirula, and a **smaller version for the Frame** (2 / 3.5 / 5 M splats). Runs in the background like a conversion, with progress, cancel and notification; the floor plan of all rooms becomes the gallery preview.
- **Corrections to choose** when putting a tour together, so the rooms meet without visible steps: *A* as delivered by the pipeline, *B* every room fitted over all its images (default), *C* plus a joint shift over the overlaps, *D* plus shift and slight turn. Borders between recordings run through doors (walking in and out of a room is ignored) or by nearest camera. Afterwards the group shows how well the overlaps match, so variants can be compared.
- **Memory estimate** before putting a tour together, with the free memory; the full version (the step that needs the most memory) can be left out.
- **Cancelled runs** are listed with how far they got and their size: resume where the pipeline can continue, start over otherwise, or delete.
- **Delete scenes** (to the desktop trash, with confirmation) and **clean up** intermediate data or training images per scene, with the disk usage of every scene – or for all scenes of a group at once.
- **Rename recordings and scenes**; a renamed scene's folder, files and log follow, and its name plate in the gallery is redrawn.
- **Keeps the PC awake** while converting, copying or downloading (systemd sleep inhibitor).
- **Offline notice** when Splat Workshop stops responding; the page reconnects by itself.
- **Setup guide** for the Arcturus pipeline and Spirula Studio on Linux ([docs/arcturus-setup.md](docs/arcturus-setup.md)).
### Changed
- **Start over** keeps the finished scene until the new one is done and brings it back if the new run fails or is cancelled.
- **Resume** after an interrupted training restarts the training (images and camera positions are reused) instead of failing.
- The target defaults to *PC only* while the Frame is not reachable.
- Floor plans of several rooms are drawn from their group; the separate *Combined floor plan (beta)* button is gone.
- Scripts are read completely before they run, so updating Splat Workshop does not disturb a running conversion.
### Fixed
- Floor plans were drawn mirror-inverted.
- LAN mode now checks the client's address, not only the `Host` header; requests from other websites are refused; malformed requests get an error instead of a dropped connection.
- Long scene names no longer push the buttons out of the card; thumbnails of recordings with quotes in the name; contrast of grey text.
## [0.1.0] – 2026-10-12
First release.
- Local **web interface** and **tray icon** around the Arcturus Vision reconstruction pipeline and Spirula Studio.
- Recordings from the Frame (over SSH) or the PC, with thumbnails; **recording check** without AI (brightness, structure, fast pans, covered lenses – second by second).
- **Conversion** with quality presets or fine-tuned values, live progress, ETA and PSNR curve; conversions survive closing the browser or the server; cancel and resume.
- **Gaussian XR gallery**: preview image and name plate, copied into the `Scenes` folder on the Frame.
- **Floor plans** (experimental) in two styles with a measuring tool.
- Configurable video player; English and German interface.
[0.4.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.3.1...v0.4.0
[0.3.1]: https://github.com/thunderheronight/splat-workshop/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.3...v0.3.0
[0.2.3]: https://github.com/thunderheronight/splat-workshop/compare/v0.2.2...v0.2.3
[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
+33
View File
@@ -0,0 +1,33 @@
# Notes for working on Splat Workshop
## Releasing – every release, also a patch
1. Only with the maintainer's explicit OK for each step: push, tag, GitHub release, editing release notes.
2. `VERSION` in `splat_workshop/server.py`, a `## [x.y.z] – date` section and the compare link in `CHANGELOG.md`.
3. Tests green locally and in GitHub Actions before tagging.
4. **Release notes always have this shape** (a patch release too – people who only read the latest
release must still see what the series brought):
- one bold line: what the series is about, plus what this release adds;
- the screenshot (`docs/groups.png`, example data only);
- `## Highlights of x.y` – the highlights of the whole minor series, copied from the `x.y.0` release;
- a link to the `x.y.0` release for all details;
- `## New in x.y.z` – what this release adds or fixes (for `x.y.0` itself: `## New in x.y`);
- `**Full changelog:**` compare link to the previous tag.
Check the finished notes against this list before publishing.
## Tests
`uv run --no-project --with pytest --with numpy --with scipy --with pillow pytest`
Expectations come from how the synthetic data is built (`tests/synth.py`), not from the code. Tests must
never touch real data, the Frame or this PC's power state (`systemctl`, `ssh`, `notify-send` are faked).
## Running scripts
`splat.sh`, `to-frame.sh`, `combine.sh`, `versions.sh` run as one `{ … exit; }` block; still, never edit
one in place while a job uses it – write a new file and `mv` it over.
## File systems
Runs may live on NTFS: no folder or file name may end with a dot or contain `:`; git there needs
`core.fileMode false`.
+77 -6
View File
@@ -1,17 +1,58 @@
# Splat Workshop (Splat-Werkstatt)
**Aufnahmen der Arcturus Vision Camera an der Steam Frame in Gaussian Splats umwandeln – und direkt in die Gaussian-XR-Galerie auf der Brille. Mit Weboberfläche, Tray-Symbol und Aufnahmeprüfung.**
**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.**
![Kein Root nötig](https://img.shields.io/badge/root-not%20needed-2ea44f) ![Kein Jailbreak](https://img.shields.io/badge/jailbreak-not%20needed-2ea44f) [![Release](https://img.shields.io/github/v/release/thunderheronight/splat-workshop)](https://github.com/thunderheronight/splat-workshop/releases) [![Tests](https://github.com/thunderheronight/splat-workshop/actions/workflows/tests.yml/badge.svg)](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)
![Splat-Werkstatt (Beispiel-Screenshot, Vorschaubilder durch Platzhalter ersetzt)](docs/screenshot.png)
Räume, eine Statue, dein Auto, ein Garten – alles, um das du mit der Kamera herumgehen kannst, wird zu einer Szene, in die du hineinsteigen kannst.
Die Splat-Werkstatt rechnet die Splats nicht selbst: Die Rekonstruktion übernehmen die Pipeline von [Arcturus Vision](https://arcturus.vision) und Spirula Studio. Die Werkstatt ist die Oberfläche drumherum – sie bereitet alles vor, startet und überwacht die Pipeline und erledigt alles davor und danach.
Arcturus Vision stellt die Rekonstruktions-Skripte und einen Spirula-Studio-Fork bereit. Bis eine Aufnahme als Szene in [Gaussian XR](https://store.steampowered.com/app/3977540/Arcturus_Vision_Gaussian_XR/) auftaucht, sind aber viele Handgriffe nötig. Die Splat-Werkstatt packt den ganzen Weg in eine lokale Weboberfläche:
1. **Aufnahme wählen** – direkt von der Frame (per SSH) oder vom PC, mit Vorschaubildern.
2. **Prüfen**, bevor eine Stunde Rechenzeit draufgeht: Helligkeit, Struktur, schnelle Schwenks, verdeckte Linse – Sekunde für Sekunde.
3. **Umwandeln** mit Qualitätsstufe (Schnell / Hoch / Maximal) oder eigenen Werten, mit Fortschritt, Restzeit und PSNR-Kurve.
4. **In die Galerie**: Vorschaubild und Namensschild im Stil der Beispielszenen, kopiert in den `Scenes`-Ordner von Gaussian XR auf der Frame.
4. **In die Galerie**: Vorschaubild und Namensschild im Stil der Beispielszenen, kopiert in den Ordner für eigene Szenen von Gaussian XR auf der Frame (`Documents/GaussianXR`).
5. **Die Wohnung zusammensetzen**: Räume gruppieren – ein Raum darf aus mehreren Aufnahmen bestehen – und zu einem Rundgang machen, durch den du gehst, voll für den PC, kleiner für die Frame.
## Neu in 0.4
- 🧽 **Fassungen bereinigen** *(experimentell)* – vier Schieberegler im Fassungen-Dialog: **Decke** (eine weiße Decke wird zum dunklen Schleier – der Schleier kommt weg, die Decke wird in ihrer eigenen Farbe geschlossen), **Boden** (Löcher gefüllt), **Dunst** (große durchsichtige Wolken; dünne Gitter und Kabel bleiben), **Rand** (was außerhalb des Raums liegt). Die Szene selbst bleibt; die bereinigte Fassung neben einer ohne vergleichen.
- 🎚️ **Fassungen nach Zweck** – *für die Frame* (eine Größe, mit den auf einer Steam Frame gemessenen fps) oder *für den PC* (alle Splats); auf Wunsch dieselbe ohne Bereinigung daneben; vorher eine Vorschau, was entsteht. Fassungen heißen nach ihren Bereinigungsstärken, *Als Vorlage* übernimmt die Einstellungen für den nächsten Versuch, eine PC-Fassung öffnet sich mit einem Klick in **Spirula Studio**, und Fassungen **klappen unter ihrer Szene oder ihrem Rundgang auf**.
- 🧭 **Aufgeräumte Knopfleisten** – was du tust, was du anschaust, was du änderst; das Aufklappen in einer eigenen Zeile.
## Neu in 0.3
- 🚪 **Räume aus mehreren Aufnahmen** – eine Gruppe als *Ein Raum aus mehreren Aufnahmen* markieren, z. B. zwei kurze Runden durchs Wohnzimmer. Beim Zusammensetzen kommt jeder Teil des Raums aus der Aufnahme, die ihn am besten gesehen hat. Die Arcturus-Pipeline nimmt höchstens 500 Bildpaare pro Aufnahme – mehrere kurze Aufnahmen geben einem Raum also deutlich mehr Detail als eine lange.
- ✂️ **Lange Videos aufteilen** – bei langen Aufnahmen bietet die Werkstatt *Video aufteilen* (volle Detailtiefe, dauert länger) oder *Video nicht aufteilen* (schneller, weniger Bildpaare pro Sekunde) an. Aufgeteilt werden die Teile nacheinander umgewandelt und von selbst zu einem Raum. Die Original-Aufnahme bleibt unverändert.
- 🏠 **Eine Wohnung aus Räumen** – eine Gruppe darf solche Räume neben einzelnen Szenen enthalten. Zusammengesetzt wird in einem Durchgang aus den Original-Aufnahmen: innerhalb eines Raums jeder Teil aus der besten Aufnahme, zwischen den Räumen laufen die Grenzen durch die Türen. Nichts wird doppelt ausgedünnt; der Grundriss benennt und misst Räume statt Aufnahmen.
- 🧩 **Bessere Übergänge zwischen Aufnahmen** – eine Stelle kommt nur noch von einer Aufnahme, die dort wirklich etwas hat (keine dunklen Löcher mehr, wo eine Kamera „von hinter dem Sofa“ hingeschaut hat), und alle Aufnahmen werden auf eine Helligkeit gebracht: Jedes Training landet bei seiner eigenen, im selben Raum bis zu einem Drittel auseinander.
- 💤 **Danach in den Standby** – am laufenden Job anhaken: Wenn alles fertig ist (alle Teile, das Zusammensetzen, das Kopieren auf die Frame), geht der PC nach einer Minute Vorwarnung in den Standby. Bis dahin bleibt er wach, auch in den Sekunden zwischen zwei Jobs.
- 🐛 **Behoben**: Aufnahmen desselben Raums gelten nicht mehr gegenseitig als „Weg hinein“ – vorher konnten sie den echten Weg aus einem anderen Raum verdecken, der sich dann einen Teil des Raums nahm.
## Neu in 0.2.3
- 🚂 **Maßstab für Modelle**, 🎚️ **Fassungen** nebeneinander, ⚡ **1 Mio. Splats als Empfehlung für die Frame**, 📂 Szenen in `Documents/GaussianXR` (Steam-Updates lassen den Ordner in Ruhe), 🥽 Reiter **Auf der Frame**, ⬇️ **Alle Aufnahmen holen**, 🗑️ **Aufnahmen einzeln löschen**. Details: [Release 0.2.3](https://github.com/thunderheronight/splat-workshop/releases/tag/v0.2.3).
## Neu in 0.2
- 🏠 **Die ganze Wohnung als eine Szene** – Räume gruppieren und *Rundgang zusammensetzen*: Die Räume eines Aufnahmedurchgangs werden aneinander ausgerichtet und zu einer begehbaren Szene verbunden, als volle Fassung für den PC und als kleinere für die Frame.
- 🎯 **Saubere Übergänge** – vier Korrekturen zur Wahl; die Gruppe zeigt, wie gut die Räume aneinanderpassen (meist auf 1–2 cm), so lässt sich vergleichen.
- 📁 **Gruppen** halten die Liste übersichtlich – nacheinander aufgenommene Räume werden automatisch vorgeschlagen.
- 🧹 **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
@@ -20,7 +61,20 @@ Arcturus Vision stellt die Rekonstruktions-Skripte und einen Spirula-Studio-Fork
- **Umwandlungen laufen unabhängig weiter** – Browser zu, Tray beendet, Neustart: Werkstatt wieder öffnen, Fortschritt ist wieder da.
- **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“).
- **Fertige Szenen**: später auf die Frame übertragen, erneut übertragen, Ordner oder Spirula Studio öffnen.
- **Räume aus mehreren Aufnahmen** *(0.3)*: Ein Raum kann aus mehreren kurzen Aufnahmen bestehen (jede bis zu 500 Bildpaare) und eine Wohnung aus solchen Räumen – jeder Teil kommt aus der Aufnahme, die ihn am besten gesehen hat, die Grenzen zwischen den Räumen laufen durch die Türen. Räume erscheinen in der Liste innerhalb ihrer Wohnung und lassen sich auch einzeln zusammensetzen, ansehen und auf die Frame bringen.
- **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>
- **Fassungen** einer Szene oder eines Rundgangs nebeneinander: weniger Splats für die Frame (0,75–2 Mio.), alle oder eine Obergrenze für den PC oder eine eigene Größe – jede mit eigenem Platz in der Galerie der Frame, zum Vergleichen, was flüssig läuft. Eine Fassung lässt sich auch **bereinigen** *(0.4, experimentell)*: Decke, Boden, Dunst und Rand, jeweils mit eigener Stärke.
- **Alle holen**: Aufnahmen von der Frame alle oder als Auswahl nacheinander holen – mit einer Benachrichtigung, sobald die Frame aus darf.
- **Aufnahmen löschen**, einzeln: auf der Frame (mit deutlicher Warnung, wenn sie noch nicht auf dem PC liegt) oder auf dem PC (in den Papierkorb).
- Reiter **Auf der Frame**: alles, was in der Galerie von Gaussian XR auf der Frame liegt, mit Vorschaubildern – und von dort aufräumen.
- **Maßstab für Modelle** wie eine Modelleisenbahn (Z, N, TT, H0, S, 0, G oder eigener): Grundriss, Größen und Messwerkzeug zeigen die Größe des Originals, und auf Wunsch zeigt Gaussian XR das Modell begehbar in Originalgröße *(an der Frame noch nicht getestet)*.
- **Grundrisse** *(experimentell)* einzelner Szenen oder einer ganzen Gruppe – Aufnahmen aus einem Durchgang fügen sich von selbst zum Grundriss der ganzen Wohnung; ein Raum aus mehreren Aufnahmen erscheint als ein Raum mit allen Wegen. 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.
- **Speicherplatz und Aufräumen**: Jede Szene zeigt, wie viel Platz sie belegt (etwa 4 GB pro Raum). Ein Klick löscht Zwischenstände oder Trainingsbilder – Szene, Grundriss und Viewer funktionieren weiter.
- **Aufnahmen umbenennen** auf dem PC; Aufnahmedatum und Prüfergebnisse bleiben erhalten.
- Deutsche und englische Oberfläche (automatisch, umschaltbar).
## Voraussetzungen, Installation, Bedienung
@@ -33,17 +87,34 @@ cd splat-workshop
./install.sh --desktop # Menüeintrag + Desktop-Verknüpfung; --autostart für das Tray beim Anmelden
```
**Läuft unter Linux** (getestet mit Nobara/Fedora und einer RTX 4070 SUPER). macOS ist ungetestet, Windows wird von der Arcturus-Pipeline noch nicht unterstützt.
**Arcturus-Pipeline und Spirula Studio einrichten:** Schritt für Schritt in [docs/arcturus-setup.de.md](docs/arcturus-setup.de.md) (Pakete, Spirula bauen, bekannte Stolperfallen).
**SSH auf der Frame einrichten** (für Download der Aufnahmen und Kopieren in die Galerie):
1. Auf der Frame: **Steam-Einstellungen → System → Enable Developer Mode**.
2. Im linken Menü unter **Developer** ganz unten **Set User Password** wählen. Es gibt kein Standardpasswort – SSH geht erst, wenn du eins vergibst.
3. Am PC: `ssh-copy-id steamos@<adresse der frame>` (Passwort einmal eingeben), danach sollte `ssh steamos@<adresse>` ohne Passwort klappen.
4. Der Frame im Router eine feste IP geben und `steamos@<diese adresse>` in der Konfiguration unter `[frame] host` eintragen.
Danach in `~/.config/splat-workshop/config.toml` die Pfade zur Arcturus-Pipeline ([arcturus.vision/apps](https://arcturus.vision/apps)), zu Spirula Studio und die Adresse der Frame eintragen. Was noch fehlt, zeigt die Werkstatt oben auf der Seite an.
Nach dem Übertragen **Gaussian XR auf der Frame neu starten**, damit die Szene in der Galerie erscheint.
Nach dem Übertragen **Gaussian XR auf der Frame neu starten**, damit die Szene in der Galerie erscheint. Die Szenen liegen in `Documents/GaussianXR`, den Steam-Updates in Ruhe lassen (bis 0.2.3 lagen sie im `Scenes`-Ordner von Gaussian XR – *Zu den eigenen Szenen verschieben* im Reiter *Auf der Frame* holt sie herüber). Alles bleibt außerdem auf dem PC, *Erneut übertragen* stellt eine Szene wieder her. Für die Frame läuft etwa **1 Mio. Splats** flüssig und sieht noch scharf aus.
## Aufnahmetipps
- Viel Licht (Tageslicht plus Raumlicht), **HDR mit 30 fps** in der Kamera-App, nicht „Linear“.
- **30–90 s pro Raum**, langsam gehen und langsam drehen, Dinge von mehreren Seiten zeigen.
- Nicht lange auf kahle Wände, Decke oder Fliesen halten.
- **Räume**: 30–90 s pro Raum, langsam gehen und langsam drehen, Dinge von mehreren Seiten zeigen.
- **Große Räume**: Die Pipeline nimmt höchstens 500 Bildpaare pro Aufnahme – bei 5 pro Sekunde (*Hoch*) ist das nach 100 s erreicht, eine 4-Minuten-Aufnahme bekommt nur noch 2 pro Sekunde. Bei einer langen Aufnahme beim Start *Video aufteilen* wählen – oder zwei oder drei kürzere Aufnahmen eines großen Raums in einem Durchgang machen und sie als *Ein Raum aus mehreren Aufnahmen* gruppieren.
- **Gegenstände**: langsam im Kreis darum herumgehen (15–40 s), Motiv in der Bildmitte halten.
- Nicht lange auf kahle Wände, Decke oder Fliesen halten. Die **Decke** aber ab und zu mitnehmen, zusammen mit der Wandoberkante oder einer Lampe: Eine weiße Decke, die kaum gefilmt wurde, wird zu einem dunklen Schleier über dem Raum.
- **Gleiches Licht**: Gegen Abend ändert sich das Tageslicht schon innerhalb von Minuten merklich, und Aufnahmen eines Raums bei verschiedenem Licht passen schlecht zusammen.
- Bewegte Personen werden zu Geisterschleiern.
## Transparenzhinweis
Dieses Projekt ist mit Hilfe einer KI entstanden: Code, Dokumentation und die Platzhalter im Beispiel-Screenshot wurden gemeinsam mit **Claude (Opus 5.5) von Anthropic** in Claude Code erstellt. Ideen, Tests auf echter Hardware, Entscheidungen und Durchsicht kamen von mir. Fehlermeldungen sind willkommen – wenn etwas komisch aussieht, kann es gut ein Fehler sein, den keiner von uns bemerkt hat.
## Lizenz
Ein Community-Tool, **nicht mit Valve oder Arcturus Vision verbunden**. Lizenz: [GNU GPL v3.0](LICENSE).
+91 -12
View File
@@ -1,6 +1,10 @@
# Splat Workshop
**Turn recordings from the Arcturus Vision Camera on your Steam Frame into Gaussian splats – and straight into the Gaussian XR gallery on the headset. With a web interface, a tray icon and a recording check.**
**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.**
![No root needed](https://img.shields.io/badge/root-not%20needed-2ea44f) ![No jailbreak](https://img.shields.io/badge/jailbreak-not%20needed-2ea44f) [![Release](https://img.shields.io/github/v/release/thunderheronight/splat-workshop)](https://github.com/thunderheronight/splat-workshop/releases) [![Tests](https://github.com/thunderheronight/splat-workshop/actions/workflows/tests.yml/badge.svg)](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)
@@ -8,12 +12,45 @@
<sub>Example screenshot – the recording thumbnails are replaced by placeholder art.</sub>
Rooms, a statue, your car, a garden – anything you can walk around with the camera can become a scene you step into.
Splat Workshop does not compute the splats itself: the reconstruction is done by the [Arcturus Vision](https://arcturus.vision) pipeline and Spirula Studio. Splat Workshop is the GUI around them – it prepares the inputs, starts and monitors the pipeline and takes care of everything before and after.
Arcturus Vision publishes the reconstruction scripts and a Spirula Studio fork, but turning a recording into a scene you can open in [Gaussian XR](https://store.steampowered.com/app/3977540/Arcturus_Vision_Gaussian_XR/) means a lot of manual steps. Splat Workshop wraps the whole path in a local web interface:
1. **Pick a recording** directly from the Frame (over SSH) or from your PC, with thumbnails.
2. **Check it** before spending an hour on it: brightness, structure, fast pans and covered lenses, second by second.
3. **Convert it** with a quality preset (Fast / High / Maximum) or fine-tuned values, with live progress, ETA and a PSNR curve.
4. **Put it in the gallery**: preview image and name plate in the look of the bundled example scenes, copied into Gaussian XR's `Scenes` folder on the Frame.
4. **Put it in the gallery**: preview image and name plate in the look of the bundled example scenes, copied into Gaussian XR's folder for personal scenes on the Frame (`Documents/GaussianXR`).
5. **Put your home together**: group the rooms – a room may be several recordings – and turn them into one tour you walk through, full size for the PC, smaller for the Frame.
## New in 0.4
- 🧽 **Clean up a version** *(experimental)* – four sliders in the versions dialog: **Ceiling** (a white ceiling turns into a dark veil – the veil goes, the ceiling is closed in its own colour), **Floor** (holes filled), **Haze** (big transparent blobs; thin bars and cables stay), **Edges** (what lies outside the room). The scene itself stays; compare the cleaned version with one without.
- 🎚️ **Versions by purpose** – *for the Frame* (one size, with the frame rate measured on a Steam Frame) or *for the PC* (all splats); optionally the same without cleaning up next to it; a preview of what will be made. Versions are named by their clean-up strengths, *As template* takes a version's settings for the next try, a PC version opens in **Spirula Studio** with one click, and versions **unfold under their scene or tour**.
- 🧭 **Tidier action bars** – what you do, what you look at, what you change; the unfolding lists on a line of their own.
## New in 0.3
- 🚪 **Rooms made of several recordings** – mark a group as *one room made of several recordings*, e.g. two short rounds through the living room. When put together, every part of the room comes from the recording that saw it best. The Arcturus pipeline uses at most 500 image pairs per recording, so several short recordings give a room far more detail than one long one.
- ✂️ **Split a long video** – for long recordings Splat Workshop offers *Split the video* (full detail, takes longer) or *Convert in one piece* (sooner, fewer image pairs per second). Split, the parts are converted one after the other and become one room by themselves. The original recording stays untouched.
- 🏠 **A home made of rooms** – a group may hold such rooms next to single scenes. It is put together from the original recordings in one go: within a room every part from the best recording, between rooms the borders run through the doors. Nothing is reduced twice; the floor plan names and measures rooms instead of recordings.
- 🧩 **Better seams between recordings** – a place only comes from a recording that really has something there (no more dark holes where a camera “looked at” a spot from behind the sofa), and all recordings are brought to one brightness: every training settles on its own, up to a third apart in the same room.
- 💤 **Standby when done** – tick it on the running job: when everything is finished (all parts, putting them together, copying to the Frame), the PC goes to standby after a minute's notice. In between, the PC is kept awake, also in the seconds between two jobs.
- 🐛 **Fixed**: recordings of the same room no longer count as “walking in” to each other – before, they could hide the real walk-in of another room, which then claimed part of the room.
## New in 0.2
- 🏠 **Your whole home as one scene** – group rooms and *put the tour together*: the rooms of one recording session are fitted to each other and joined into one walkable scene, in a full version for the PC and a smaller one for the Frame.
- 🎯 **Seamless transitions** – four corrections to choose from; the group shows how well the rooms meet (typically within 1–2 cm), so you can compare.
- 📁 **Groups** keep the list tidy – suggested automatically for rooms recorded one after another.
- 🧹 **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
@@ -22,17 +59,35 @@ Arcturus Vision publishes the reconstruction scripts and a Spirula Studio fork,
- **Conversions survive everything**: they run as their own process; close the browser, quit the tray, restart – reopen Splat Workshop and you see the progress again.
- **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).
- **Finished scenes**: copy to the Frame later, re-copy, open the folder or open the scene in Spirula Studio.
- **Rooms from several recordings** *(0.3)*: a room can be made of several short recordings (each up to 500 image pairs), and a home of such rooms – every part comes from the recording that saw it best, the borders between rooms run through the doors. Rooms appear inside their home in the list and can also be put together, viewed and copied to the Frame on their own.
- **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>
- **Versions** of a scene or tour side by side: fewer splats for the Frame (0.75–2 M), all or a limit for the PC, or your own size – each with its own place in the Frame's gallery, so you can compare what runs smoothly. A version can also be **cleaned up** *(0.4, experimental)*: ceiling, floor, haze and edges, each with its own strength.
- **Fetch all** recordings from the Frame, or a selection, one after the other – with a notification when the Frame can be switched off.
- **Delete a recording**, one at a time: on the Frame (with a clear warning if it is not on the PC yet) or on the PC (to the trash).
- **On the Frame** tab: everything in Gaussian XR's gallery on the Frame with preview images, and taking things off it.
- **Scale for models** like a model railway (Z, N, TT, H0, S, 0, G or your own): floor plan, sizes and the measuring tool show the original's size, and optionally Gaussian XR shows the model in its original size to walk through *(not tested on the Frame yet)*.
- **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; a room made of several recordings is drawn as one room with all its paths. 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>
- **Finished scenes**: copy to the Frame later, re-copy, rename (folder and name plate follow), delete (to the trash), open the folder or open the scene in Spirula Studio.
- **Disk usage and clean-up**: each scene shows how much space it takes (about 4 GB for a room). One click deletes the intermediate data or the training images – the scene, floor plan and viewer keep working.
- **Cancelled runs** are listed with how far they got: resume, start over or delete.
- **Rename recordings** on the PC; the recording date and check results are kept.
- English and German interface (automatic, switchable). More languages welcome – see `web/i18n.js` and `splat_workshop/i18n.py`.
## Requirements
- **Linux** with a GPU that runs Spirula Studio (Vulkan; tested on an NVIDIA RTX 4070 SUPER, 12 GB). Arcturus does not support its reconstruction scripts on Windows yet.
- **Linux** with a GPU that runs Spirula Studio (Vulkan; tested on Nobara/Fedora with an NVIDIA RTX 4070 SUPER, 12 GB). Splat Workshop uses Linux desktop tools (`xdg-open`, `notify-send`, KDE/GNOME file dialogs). macOS is untested – the Arcturus pipeline runs there, Splat Workshop would need small changes. Windows is not supported, since Arcturus does not support its reconstruction scripts on Windows yet.
- A **Steam Frame** with the **Arcturus Vision Camera** – recordings are the `*_stereo.mp4` files from the Arcturus camera app (two video tracks plus a calibration/pose track). Normal videos do not work.
- **Python 3.11+**, **ffmpeg/ffprobe**, **[uv](https://docs.astral.sh/uv/)**, **ssh**, **curl**
- **Node.js** for [`splat-transform`](https://github.com/playcanvas/splat-transform): `npm install -g @playcanvas/splat-transform`
- The **Arcturus reconstruction scripts** (`av1-3dgs-pipeline`) from [arcturus.vision/apps](https://arcturus.vision/apps) – not included here, download them yourself.
- **Spirula Studio**, built from the Arcturus fork exactly as described in the pipeline's README (branch `sfm-calibrated-manifest`, Vulkan backend).
→ **Step-by-step: [Setting up the Arcturus pipeline on Linux](docs/arcturus-setup.md)** (packages, Spirula build, known pitfalls).
- Optional for the tray icon: **PySide6** (`sudo dnf install python3-pyside6` / `sudo apt install python3-pyside6.qtwidgets`) – otherwise `uv` fetches it on first start. On GNOME you need the *AppIndicator* extension to see tray icons.
## Install
@@ -60,9 +115,11 @@ Splat Workshop shows what is still missing at the top of the page.
Downloading recordings and copying scenes uses SSH with a key:
1. On the Frame, in desktop mode: enable SSH and set a password for `steamos` (`passwd`, then `sudo systemctl enable --now sshd`).
2. On your PC: `ssh-copy-id steamos@<frame address>` and check that `ssh steamos@<frame address>` logs in without a password.
3. Give the Frame a fixed address in your router so it does not change.
1. On the Frame: **Steam Settings → System → Enable Developer Mode**.
2. In the left-hand menu, scroll down to **Developer**, and near the bottom of that page choose **Set User Password**. There is no default password – SSH works only after you set one. ([Valve's docs](https://partner.steamgames.com/doc/steamhardware/steamframe/setup))
3. Find the Frame's address: its IP is shown under **Steam Settings → Internet** (or in your router); the hostname can be set under **Steam Settings → System → Hostname**.
4. On your PC: `ssh-copy-id steamos@<frame address>` (enter the password once), then check that `ssh steamos@<frame address>` logs in without asking.
5. Give the Frame a fixed IP address in your router so it does not change, and put `steamos@<that address>` into `[frame] host` in the config.
## Usage
@@ -74,10 +131,11 @@ Start **Splat Workshop** from the menu (or `./splat-workshop`). The tray icon ap
| `./splat-workshop --background` | tray icon only (autostart) |
| `./splat-workshop --no-tray` | without tray; the server stops itself after 20 idle minutes |
| `./splat-workshop --server` | server in the foreground |
| `scripts/splat.sh <recording> "<name>"` | convert on the command line (`QUALITY=fast\|high\|max`, `NO_UPLOAD=1`, `--resume`) |
| `scripts/splat.sh <recording> "<name>"` | convert on the command line (`QUALITY=fast\|high\|max`, `NO_UPLOAD=1`, `PART=120-245` for a stretch, `--resume`) |
| `scripts/to-frame.sh <run> "<name>"` | (re)copy a finished scene to the Frame |
| `scripts/combine.sh <name> "<display name>" <run>=<label> ...` | put scenes together into a tour (`ALIGN=frame\|images\|shift\|rigid`, `FRAME_SPLATS`, `FULL=0`, `NO_UPLOAD=1`, `ROOMS='["Kitchen","Kitchen","Hallway"]'` for rooms made of several recordings) |
After copying, **restart Gaussian XR** on the Frame so the new scene appears in the gallery. A Steam “verify files” or app update can reset Gaussian XR's `Scenes` folder – Splat Workshop also keeps a copy in `~/Documents/GaussianSplats` on the Frame, and *Copy again* restores a scene.
After copying, **restart Gaussian XR** on the Frame so the new scene appears in the gallery. Scenes go to `Documents/GaussianXR`, which Steam updates leave alone (Splat Workshop 0.2.3 and older used Gaussian XR's own `Scenes` folder – *Move to own scenes* in the *On the Frame* tab moves them over). Everything also stays on the PC, and *Copy again* restores a scene. For the Frame, about **1 M splats** runs smoothly and still looks sharp. The *On the Frame* tab under the finished scenes shows what is in the gallery on the Frame and takes things off it.
### Access from other devices
@@ -85,10 +143,13 @@ Set `lan = true` in `[server]`. The page is then reachable at `http://<pc addres
## Recording tips
- **Light**: daylight plus room lights. Dark recordings get noisy and patchy.
- **Light**: daylight plus room lights indoors. Dark recordings get noisy and patchy.
- **HDR, 30 fps** in the camera app works best; the pipeline tone-maps HLG automatically. Don't use “Linear”.
- **30–90 s per room**, walk slowly, turn slowly. Look at things from several sides.
- Avoid pointing at **bare walls, ceilings or tiles** for long – there is nothing for the software to hold on to.
- **Rooms**: 30–90 s each, walk slowly, turn slowly, look at things from several sides.
- **Large rooms**: the pipeline uses at most 500 image pairs per recording – at 5 per second (*High*) that is reached after 100 s, a 4-minute recording only gets 2 per second. For a long recording, choose *Split the video* when starting – or make two or three shorter recordings of a large room in one session and group them as *one room made of several recordings*.
- **Objects**: walk around them in a slow circle (15–40 s) and keep them in the middle of the picture.
- Avoid pointing at **bare walls, ceilings or tiles** for long – there is nothing for the software to hold on to. But do include the **ceiling** now and then, together with the top of the walls or a lamp: a white ceiling that was hardly filmed turns into a dark veil hanging over the room.
- **Same light** throughout: daylight changes noticeably within minutes towards the evening, and recordings of one room taken at different light do not match well.
- Moving people or pets become ghostly smears.
## What the check measures
@@ -116,6 +177,7 @@ It is a measurement, not image recognition: it does not detect people or moving
| Maximum | 8 per second (100–500) | full | 3 M | 50k | ~1.5 h+ |
\* RTX 4070 SUPER. Spirula keeps training images on disk, so 12 GB of VRAM are enough for “High”. A run needs a few GB of free disk space.
The 100–500 image pairs per recording are a limit of the Arcturus pipeline. Above 100 s (*High*) or 63 s (*Maximum*) a recording gets fewer pairs per second, and *Maximum* adds no images any more – see *Large rooms* above.
## Files
@@ -123,13 +185,30 @@ It is a measurement, not image recognition: it does not detect people or moving
config: ~/.config/splat-workshop/config.toml
recordings: [paths] recordings (downloaded from the Frame)
scenes: [paths] runs/<name>/ (scene.sog, spirula/scene.ply, gallery files, intermediate data)
tours and rooms: [paths] runs/<group>/ (scene.sog = Frame version, full/ = full version, spirula/scene.ply, combine.json)
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`), rooms made of several recordings and homes made of rooms, 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.
## Transparency note
This project was written with the help of AI: the code, the documentation and the example screenshot placeholders were created together with **Claude (Opus 5.5) by Anthropic**, using Claude Code. Ideas, testing on real hardware, decisions and review were done by me. Bug reports are welcome – if something looks off, it may well be a mistake neither of us noticed.
## Credits & license
Splat Workshop is a community tool and **not affiliated with Valve or Arcturus Vision**. It uses the [Arcturus Vision](https://arcturus.vision) reconstruction scripts and their [Spirula Studio](https://github.com/arcturus-vision/spirula-studio) fork, and PlayCanvas [splat-transform](https://github.com/playcanvas/splat-transform) – please follow their licenses and terms.
+6 -1
View File
@@ -23,10 +23,15 @@ runs = "~/SplatWorkshop/runs"
# Additional folders that are scanned for *_stereo.mp4 recordings.
extra_recording_dirs = ["~/Downloads", "~/Videos"]
# Program for "Open in video player", e.g. "mpv" or "vlc --fullscreen".
# Leave empty to use the system default for MP4 files.
video_player = ""
[frame]
# SSH target of your Steam Frame. Leave empty to work without the Frame
# (no downloading of recordings, no copying into the Gaussian XR gallery).
# The Frame needs SSH enabled and your SSH key installed (ssh-copy-id steamos@<address>).
# On the Frame: Steam Settings > System > Enable Developer Mode, then Developer > Set User Password.
# Then on this PC: ssh-copy-id steamos@<address>
host = ""
# host = "steamos@192.168.1.50"
+121
View File
@@ -0,0 +1,121 @@
# Die Arcturus-Pipeline unter Linux einrichten
[English](arcturus-setup.md)
Die Splat-Werkstatt braucht zwei Dinge von Arcturus Vision: die **Rekonstruktions-Skripte** und **Spirula Studio** (deren Fork, mit Vulkan gebaut). So wurde es auf dem Rechner eingerichtet, auf dem die Splat-Werkstatt entstanden ist (Nobara 44 / Fedora, NVIDIA RTX 4070 SUPER). Maßgeblich ist die [README der Pipeline](https://arcturus.vision/apps) – wenn dort für eine neuere Version etwas anderes steht, gilt das.
Rechne mit etwa 30 Minuten, das meiste davon Kompilieren. Alles kann in einem Ordner liegen, z. B. `~/arcturus`:
```
~/arcturus/
├── av1-3dgs-pipeline/ Rekonstruktions-Skripte (Download)
└── spirula-studio/ Spirula-Studio-Fork (git clone + bauen)
└── build_vulkan/spirula das Programm, das die Splat-Werkstatt aufruft
```
## 1. Pakete
Ein C++17-Compiler, CMake, Ninja, git, Vulkan-Header und -Loader und ein Grafiktreiber mit Vulkan. Dazu die Werkzeuge, die die Splat-Werkstatt selbst braucht.
**Fedora / Nobara**
```bash
sudo dnf install gcc-c++ cmake ninja-build git vulkan-headers vulkan-loader-devel vulkan-tools \
ffmpeg uv npm
```
**Ubuntu / Debian**
```bash
sudo apt install build-essential cmake ninja-build git libvulkan-dev vulkan-tools ffmpeg nodejs npm
curl -LsSf https://astral.sh/uv/install.sh | sh # uv gibt es bei älteren Versionen nicht als Paket
```
Prüfen, ob Vulkan deine Grafikkarte sieht (und nicht nur einen Software-Renderer wie *llvmpipe*):
```bash
vulkaninfo --summary
```
Taucht nur llvmpipe auf, zuerst den Vulkan-Treiber für deine GPU installieren (NVIDIA: proprietärer Treiber; AMD/Intel: Mesa-Vulkan-Treiber). Das CUDA-Toolkit wird **nicht** gebraucht.
## 2. Rekonstruktions-Skripte
Das ZIP der **AV1 → 3DGS Pipeline** von [arcturus.vision/apps](https://arcturus.vision/apps) laden und entpacken:
```bash
mkdir -p ~/arcturus && cd ~/arcturus
unzip ~/Downloads/av1-3dgs-pipeline-*.zip -d av1-3dgs-pipeline
```
Im Ordner muss direkt `av1_to_3dgs.py` liegen (hat das ZIP eine zusätzliche Ordnerebene, den Inhalt eine Ebene hochschieben). Python-Pakete musst du nicht installieren – `uv` holt sie beim ersten Lauf.
## 3. Spirula Studio
Die Pipeline ist gegen genau einen Spirula-Stand getestet, und ihre README nennt ihn (`git -C spirula-studio checkout <commit>`). Genau den nehmen – Spirula wird aktiv weiterentwickelt, und eine neuere Version kann die Kommandozeilen-Optionen ändern, die die Pipeline aufruft. Lädst du eine neuere Pipeline, nennt sie den passenden Spirula-Stand, das bleibt also von selbst aktuell.
Diese Befehle lesen den Stand aus der Pipeline-README, du musst ihn nicht abtippen:
```bash
cd ~/arcturus
REV=$(grep -oE 'spirula-studio checkout [0-9a-f]{40}' av1-3dgs-pipeline/README.md | awk '{print $3}')
echo "Spirula-Stand: $REV" # darf nicht leer sein – sonst in der README nachschauen
git clone https://github.com/arcturus-vision/spirula-studio.git
cd spirula-studio
git fetch origin "$REV" # klappt auch, wenn der Commit nicht mehr an einer Branch-Spitze liegt
git checkout "$REV"
```
Bauen (Vulkan, mit Spirulas eigenem Structure-from-Motion):
```bash
bash build_develop.bash -DSS_BACKEND=vulkan -DSS_BUILD_GUI=OFF -DSS_BUILD_SFM=ON -DSS_BUILD_SAM=OFF
```
Der Build lädt seinen eigenen Shader-Compiler und dauert eine Weile. Testen:
```bash
./build_vulkan/spirula sfm auto --help
```
**Optional:** Mit `-DSS_BUILD_GUI=ON` bekommst du zusätzlich Spirulas grafischen Viewer, den der Knopf *Spirula* in der Splat-Werkstatt öffnet, um eine fertige Szene am PC anzuschauen. Dafür braucht der Build weitere Desktop-Entwicklerpakete (X11/Wayland); CMake sagt, welche fehlen.
## 4. splat-transform
Wandelt das Ergebnis in das kompakte SOG-Format, das Gaussian XR lädt:
```bash
npm install -g @playcanvas/splat-transform
```
Scheitert `npm install -g` an fehlenden Rechten, ins eigene Home installieren – ohne root:
```bash
npm install -g --prefix ~/arcturus/node @playcanvas/splat-transform
```
und in der Config unter `[paths]` `splat_transform = "~/arcturus/node/bin/splat-transform"` eintragen (mit Unterstrich – `splat-transform =` ist keine Einstellung und wird ignoriert; die Splat-Werkstatt weist darauf hin).
**Immer den vollen Pfad in die Config schreiben**, wenn splat-transform nur im PATH deiner Shell steht (`.bashrc`, `.zshrc`): Vom Desktop oder Tray gestartet liest die Splat-Werkstatt diese Dateien nicht und findet es sonst nicht. `install.sh` trägt die Zeile ein oder gibt sie aus.
## 5. Der Splat-Werkstatt sagen, wo alles liegt
In `~/.config/splat-workshop/config.toml`:
```toml
[paths]
pipeline = "~/arcturus/av1-3dgs-pipeline"
spirula = "~/arcturus/spirula-studio/build_vulkan/spirula"
```
Splat-Werkstatt neu starten (Tray → Beenden, dann wieder starten). Fehlt noch etwas, steht es oben auf der Seite.
## Wenn etwas nicht klappt
| Meldung / Problem | Ursache / Lösung |
|---|---|
| `av.open() got an unexpected keyword argument 'metadata_errors'` | PyAV 19 hat diese Option entfernt. Ältere Version festlegen: im `# /// script`-Kopf oben in `av1_to_3dgs.py` und `av1_colmap.py` `"av>=18.1"` in `"av>=18.1,<19"` ändern. (Mit der Pipeline vom Herbst 2026 nötig; neuere Versionen haben es evtl. behoben.) Die Splat-Werkstatt (ab 0.4.1) weist schon vor dem Start in ihren Einrichtungshinweisen darauf hin und nennt den Grund, falls eine Umwandlung daran scheitert. |
| `Could not select unique evenly spaced capture instants` | Aufnahme zu kurz. Die Pipeline braucht mindestens 100 Aufnahmezeitpunkte – mindestens 10 Sekunden aufnehmen, besser 30+. |
| Spirula findet keine GPU / nutzt llvmpipe | Vulkan-Treiber fehlt, siehe Schritt 1. |
| Die Szene taucht in Gaussian XR nicht auf | Sie muss als echter Ordner (kein Symlink) mit den Galeriebildern in `Scenes/` von Gaussian XR liegen – das macht der Knopf *Auf die Frame*. Ein Steam-Update oder „Dateien überprüfen“ von Gaussian XR kann den Ordner zurücksetzen; dann die Szene einfach neu übertragen. |
| Nur wenige Bilder registriert / unscharfes Ergebnis | Meist liegt es an der Aufnahme: zu dunkel, zu schnell oder zu wenig Struktur. Vorher *Aufnahme prüfen* laufen lassen. |
+121
View File
@@ -0,0 +1,121 @@
# Setting up the Arcturus pipeline on Linux
[Deutsch](arcturus-setup.de.md)
Splat Workshop needs two things from Arcturus Vision: the **reconstruction scripts** and **Spirula Studio** (their fork, built with Vulkan). This is how it was set up on the machine Splat Workshop was developed on (Nobara 44 / Fedora, NVIDIA RTX 4070 SUPER). The [pipeline's own README](https://arcturus.vision/apps) is the reference – if it says something different for a newer version, follow it.
Plan for about 30 minutes, most of it compiling. Everything can live in one folder, e.g. `~/arcturus`:
```
~/arcturus/
├── av1-3dgs-pipeline/ reconstruction scripts (download)
└── spirula-studio/ Spirula Studio fork (git clone + build)
└── build_vulkan/spirula the binary Splat Workshop calls
```
## 1. Packages
A C++17 compiler, CMake, Ninja, git, the Vulkan headers and loader, and a GPU driver with Vulkan support. Plus the tools Splat Workshop itself needs.
**Fedora / Nobara**
```bash
sudo dnf install gcc-c++ cmake ninja-build git vulkan-headers vulkan-loader-devel vulkan-tools \
ffmpeg uv npm
```
**Ubuntu / Debian**
```bash
sudo apt install build-essential cmake ninja-build git libvulkan-dev vulkan-tools ffmpeg nodejs npm
curl -LsSf https://astral.sh/uv/install.sh | sh # uv is not packaged on older releases
```
Check that Vulkan sees your graphics card (not only a software renderer like *llvmpipe*):
```bash
vulkaninfo --summary
```
If only llvmpipe shows up, install the Vulkan driver for your GPU first (NVIDIA: the proprietary driver; AMD/Intel: Mesa's Vulkan drivers). The CUDA toolkit is **not** needed.
## 2. Reconstruction scripts
Download the **AV1 → 3DGS pipeline** zip from [arcturus.vision/apps](https://arcturus.vision/apps) and unpack it:
```bash
mkdir -p ~/arcturus && cd ~/arcturus
unzip ~/Downloads/av1-3dgs-pipeline-*.zip -d av1-3dgs-pipeline
```
The folder must directly contain `av1_to_3dgs.py` (if the zip has an extra folder level, move its contents up). You don't need to install Python packages – `uv` fetches them on the first run.
## 3. Spirula Studio
The pipeline is tested against one specific Spirula revision, and its README names it (`git -C spirula-studio checkout <commit>`). Use exactly that one – Spirula is under active development, and a newer version can change the command line options the pipeline calls. When you download a newer pipeline, it names the matching Spirula revision, so this stays up to date by itself.
These commands read the revision from the pipeline README, so you don't have to copy it by hand:
```bash
cd ~/arcturus
REV=$(grep -oE 'spirula-studio checkout [0-9a-f]{40}' av1-3dgs-pipeline/README.md | awk '{print $3}')
echo "Spirula revision: $REV" # must not be empty – otherwise look it up in the README
git clone https://github.com/arcturus-vision/spirula-studio.git
cd spirula-studio
git fetch origin "$REV" # also works if the commit is no longer on a branch tip
git checkout "$REV"
```
Build (Vulkan backend, with Spirula's own structure-from-motion):
```bash
bash build_develop.bash -DSS_BACKEND=vulkan -DSS_BUILD_GUI=OFF -DSS_BUILD_SFM=ON -DSS_BUILD_SAM=OFF
```
The build downloads its own shader compiler and takes a while. Test it:
```bash
./build_vulkan/spirula sfm auto --help
```
**Optional:** with `-DSS_BUILD_GUI=ON` instead you also get Spirula's graphical viewer, which the *Spirula* button in Splat Workshop opens to look at a finished scene on the PC. The GUI build needs additional desktop development packages (X11/Wayland); CMake names the ones that are missing.
## 4. splat-transform
Converts the result into the compact SOG format that Gaussian XR loads:
```bash
npm install -g @playcanvas/splat-transform
```
If `npm install -g` fails with a permission error, install it into your home instead – no root needed:
```bash
npm install -g --prefix ~/arcturus/node @playcanvas/splat-transform
```
and set `splat_transform = "~/arcturus/node/bin/splat-transform"` under `[paths]` in the config (with an underscore – `splat-transform =` is not a setting and is ignored; Splat Workshop points that out).
**Always set the full path in the config** if splat-transform is only on the PATH of your shell (`.bashrc`, `.zshrc`): started from the desktop or the tray, Splat Workshop does not read those files and would not find it. `install.sh` writes or prints the line for you.
## 5. Tell Splat Workshop where everything is
In `~/.config/splat-workshop/config.toml`:
```toml
[paths]
pipeline = "~/arcturus/av1-3dgs-pipeline"
spirula = "~/arcturus/spirula-studio/build_vulkan/spirula"
```
Restart Splat Workshop (tray → Quit, then start it again). If something is still missing, the page says so at the top.
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| `av.open() got an unexpected keyword argument 'metadata_errors'` | PyAV 19 removed that option. Pin the older version: in the `# /// script` header at the top of `av1_to_3dgs.py` and `av1_colmap.py`, change `"av>=18.1"` to `"av>=18.1,<19"`. (Was needed with the pipeline from autumn 2026; newer versions may have fixed it.) Splat Workshop (0.4.1 and newer) points this out in its setup hints before you start, and names it if a conversion fails because of it. |
| `Could not select unique evenly spaced capture instants` | Recording too short. The pipeline needs at least 100 capture instants – record at least 10 seconds, better 30+. |
| Spirula finds no GPU / uses llvmpipe | Vulkan driver missing, see step 1. |
| The scene does not appear in Gaussian XR | It must be a real folder in Gaussian XR's `Scenes/` (no symlink) with the gallery images – Splat Workshop's *To the Frame* button does this for you. A Steam update or “verify files” of Gaussian XR can reset that folder; just copy the scene again. |
| Only very few images registered / blurry result | Usually the recording: too dark, too fast, or too little structure. Run *Check recording* in Splat Workshop first. |
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

+26 -5
View File
@@ -26,19 +26,40 @@ for tool in ffmpeg ffprobe uv ssh curl; do
command -v "$tool" >/dev/null && echo " ✓ $tool" || { echo " ✗ $tool"; missing=1; }
done
command -v splat-transform >/dev/null && echo " ✓ splat-transform" ||
echo " ! splat-transform not on PATH (npm install -g @playcanvas/splat-transform, or set it in the config)"
echo " ! splat-transform not on PATH (npm install -g --prefix ~/.local @playcanvas/splat-transform needs no root; or set its path in the config)"
python3 -c 'import PySide6' 2>/dev/null && echo " ✓ PySide6 (tray icon)" ||
echo " · PySide6 not installed – uv will fetch it on first start for the tray icon"
[ $missing = 0 ] || echo "Install the missing tools first (see README)."
mkdir -p "$CONF" "$APPS" "$ICONS"
created=0
if [ ! -f "$CONF/config.toml" ]; then
cp "$ROOT/config.example.toml" "$CONF/config.toml"
echo "Created $CONF/config.toml – set the paths to the Arcturus pipeline and Spirula, and your Frame's address."
created=1
echo "Created $CONF/config.toml from config.example.toml – set the paths to the Arcturus pipeline and Spirula, and your Frame's address."
else
echo "Keeping existing $CONF/config.toml"
echo "Keeping existing $CONF/config.toml (all settings with explanations: $ROOT/config.example.toml)"
fi
# Started from the desktop, Splat Workshop does not see a PATH set in .bashrc/.zshrc: give it the full path
st=$(command -v splat-transform || true)
conf_st=$(python3 -c 'import sys, tomllib
print(tomllib.load(open(sys.argv[1], "rb")).get("paths", {}).get("splat_transform", ""))' "$CONF/config.toml" 2>/dev/null || true)
if [ -n "$st" ] && [ -z "$conf_st" ]; then
if [ $created = 1 ]; then
sed -i "s|^splat_transform = \"\"|splat_transform = \"$st\"|" "$CONF/config.toml"
echo " ✓ splat_transform = \"$st\" written to the config"
else
echo " ! Add this line under [paths] in $CONF/config.toml – started from the desktop,"
echo " Splat Workshop does not see the PATH of your shell:"
echo " splat_transform = \"$st\""
fi
fi
# Settings Splat Workshop does not know (typos such as "splat-transform" instead of "splat_transform")
(cd "$ROOT" && python3 -c 'from splat_workshop import config
for key, hint in config.load().unknown:
print(f" ! Unknown setting \"{key}\" in the config" + (f" – did you mean \"{hint}\"?" if hint else " – it is ignored"))') || true
cp "$ROOT/web/icon.svg" "$ICONS/splat-workshop.svg"
entry() {
cat <<EOF
@@ -46,8 +67,8 @@ entry() {
Type=Application
Name=Splat Workshop
Name[de]=Splat-Werkstatt
Comment=Turn Steam Frame camera recordings into Gaussian splats
Comment[de]=Steam-Frame-Aufnahmen in Gaussian Splats umwandeln
Comment=GUI for the Arcturus Vision splat pipeline (Steam Frame)
Comment[de]=Oberfläche für die Splat-Pipeline von Arcturus Vision (Steam Frame)
Exec="$ROOT/splat-workshop" $1
Icon=splat-workshop
Terminal=false
+2
View File
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests
+209
View File
@@ -0,0 +1,209 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy", "scipy", "pillow"]
# ///
"""Clean up a finished scene or room: the things a reconstruction of a real room tends to get wrong.
Usage: uv run clean.py <in.ply> <out.ply> [--ceiling S] [--floor S] [--haze S] [--outside S]
Every strength S runs from 0 (off) to 1 (strongest). The room is found as for the floor plan (walls 0.9 to
1.9 m above the floor, turned to them; Y points down).
--ceiling A white ceiling has no texture: the reconstruction finds nothing to hold on to, leaves the
ceiling thin and hangs a dark veil under it. Takes out the veil (dark or thin splats from
10 cm to 60 cm under the ceiling – how deep grows with S; dark but solid things like a cupboard
top or a bird cage stay if they are within 50 cm of a wall) and everything above the ceiling
(found where walls and ceiling meet), and puts in a closed ceiling in the colour of what there
is of it nearby.
--floor Fills holes in the floor (where the floor itself has too little: more with S) in the colour of
the floor nearby, and takes out what lies under it.
--haze Takes out big, nearly transparent blobs floating in the room. Measured by their *smallest*
axis: long thin splats (wires, bars of a cage, cables) are no haze. More with S.
--outside Takes out what lies well outside the room (beyond the walls, above the ceiling, under the
floor): from 1 m beyond it (S near 0) to 30 cm (S = 1).
Prints what it did; the input is never changed.
"""
import argparse
import sys
from pathlib import Path
import numpy as np
from scipy.spatial import cKDTree
sys.path.insert(0, str(Path(__file__).resolve().parent))
import floorplan as F # noqa: E402 (room box, wall angle)
C0 = 0.28209479177387814
STEP = 0.02 # grid of a filled ceiling or floor (m)
def read_ply(path):
with open(path, "rb") as f:
header = b""
while not header.endswith(b"end_header\n"):
line = f.readline()
if not line:
sys.exit(f"Not a PLY file: {path}")
header += line
props = [l.split()[-1].decode() for l in header.splitlines() if l.startswith(b"property")]
n = int(next(l for l in header.splitlines() if l.startswith(b"element vertex")).split()[-1])
v = np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
return header, v
def write_ply(path, header, v):
lines = [b"element vertex %d\n" % len(v) if l.startswith(b"element vertex") else l
for l in header.splitlines(keepends=True)]
tmp = Path(str(path) + ".tmp")
with open(tmp, "wb") as f:
f.write(b"".join(lines))
v.tofile(f)
tmp.replace(path)
class Room:
"""Where floor, ceiling and walls are, from the solid splats."""
def __init__(self, xyz, alpha):
solid = xyz[alpha > 0.5]
if len(solid) < 1000:
sys.exit("Too few solid splats to find the room")
self.floor, _ = F.heights(solid[:, 1])
up = self.floor - solid[:, 1]
walls = solid[(up > 0.9) & (up < 1.9)][:, [0, 2]]
sub = walls[np.random.default_rng(0).choice(len(walls), min(len(walls), 150_000), replace=False)]
self.angle = F.wall_angle(sub, 0.03)
box = F.room_box(F.rotate(walls, self.angle), 0.05)
if box is None:
sys.exit("No walls found")
self.lo, self.hi = box
self.up = self.floor - xyz[:, 1] # height above the floor of every splat
self.p = F.rotate(xyz[:, [0, 2]], self.angle) # floor plan position, walls axis-aligned
def within(self, margin):
"""Splats inside the walls, `margin` metres away from them (negative: beyond them)."""
return np.all((self.p > self.lo + margin) & (self.p < self.hi - margin), axis=1)
def grid(self):
g0, g1 = np.meshgrid(np.arange(self.lo[0], self.hi[0], STEP), np.arange(self.lo[1], self.hi[1], STEP),
indexing="ij")
return np.stack([g0.ravel(), g1.ravel()], 1)
def layer(room, v, at, cells, ref, rgb, alpha):
"""Flat splats (3 cm wide, 2 mm thick) at height `at` above the floor in the floor plan cells, in the
colour of the reference splats `ref` nearby (half of it the overall colour: calm, no blotches)."""
overall = np.average(rgb[ref], weights=alpha[ref], axis=0) if ref.any() else np.array([0.8, 0.8, 0.8])
col = np.tile(overall, (len(cells), 1))
if ref.sum() >= 10:
d, j = cKDTree(room.p[ref]).query(cells, k=32, distance_upper_bound=0.5, workers=-1)
ok = np.isfinite(d)
j = np.where(ok, j, 0)
w = np.where(ok, alpha[ref][j] / (0.05 + np.where(ok, d, 1)), 0)
near = (w[:, :, None] * rgb[ref][j]).sum(1) / np.maximum(w.sum(1, keepdims=True), 1e-9)
col = np.where(w.sum(1, keepdims=True) > 0, 0.5 * near + 0.5 * overall, overall)
new = np.zeros(len(cells), dtype=v.dtype)
xz = F.rotate(cells, -room.angle)
new["x"], new["z"], new["y"] = xz[:, 0], xz[:, 1], room.floor - at
for k in range(3):
new[f"f_dc_{k}"] = (np.clip(col[:, k], 0, 1) - 0.5) / C0
new["opacity"] = np.log(0.98 / 0.02)
new["scale_0"] = new["scale_2"] = np.log(0.015)
new["scale_1"] = np.log(0.002) # flat: y is up/down
new["rot_0"] = 1.0
return new
def ceiling(room, v, rgb, lum, alpha, s):
"""Height of the ceiling, what to take out (veil, above it), the new ceiling."""
inside = room.within(0)
# where walls and ceiling meet, solid splats pile up: the strongest line near the walls above 2 m
# (the ceiling itself, white and thin, is no good to find it)
corner = inside & ~room.within(0.3) & (alpha > 0.5) & (room.up > 2.0) & (room.up < 3.5)
if corner.sum() < 100:
print("Ceiling: not found, left as it is")
return np.zeros(len(v), bool), v[:0]
h, edges = np.histogram(room.up[corner], bins=np.arange(2.0, 3.5, 0.02))
h = np.convolve(h, [1, 2, 1], "same")
# the highest strong line: a cupboard or shelf top by the wall can be strong, too, but lies lower
H = float(edges[np.flatnonzero(h >= 0.5 * h.max())[-1]] + 0.01)
bright = inside & (lum > 0.3) & (room.up > 2.0)
depth = 0.1 + 0.5 * s
under = (room.up > H - depth) & (room.up < H - 0.03)
veil = under & ((room.within(0.15) & (alpha < 0.3)) | (room.within(0.5) & (lum < 0.3)))
above = inside & (room.up > H + 0.05)
new = layer(room, v, H, room.grid(), bright & (np.abs(room.up - H) < 0.08), rgb, alpha)
print(f"Ceiling {H:.2f} m above the floor: veil ({depth:.2f} m deep) {veil.sum():,} splats and "
f"{above.sum():,} above it out, closed with {len(new):,}")
return veil | above, new
def floor(room, v, rgb, alpha, s):
"""Holes in the floor filled, what lies under it out."""
inside = room.within(0)
below = inside & (room.up < -0.05)
near = inside & (np.abs(room.up) < 0.04)
cells = room.grid()
n = (len(np.arange(room.lo[0], room.hi[0], STEP)), len(np.arange(room.lo[1], room.hi[1], STEP)))
idx = np.clip(np.floor((room.p[near] - room.lo) / STEP).astype(np.int64), 0, np.array(n) - 1)
n0 = n[1]
cover = np.bincount(idx[:, 0] * n0 + idx[:, 1], alpha[near], len(cells))
# a cell and its neighbours: a floor a centimetre off still counts
c2 = cover.reshape(-1, n0)
pad = np.pad(c2, 1)
around = sum(pad[1 + dx:1 + dx + c2.shape[0], 1 + dy:1 + dy + c2.shape[1]]
for dx in (-1, 0, 1) for dy in (-1, 0, 1)).ravel()
hole = around < 1.5 * s
new = layer(room, v, 0.0, cells[hole], near & (alpha > 0.3), rgb, alpha)
print(f"Floor: {below.sum():,} splats under it out, {hole.sum():,} holes ({100 * hole.mean():.0f} %) filled")
return below, new
def haze(v, alpha, s):
smallest = np.exp(np.stack([v["scale_0"], v["scale_1"], v["scale_2"]], 1).astype(np.float64)).min(1)
size, opacity = 0.04 - 0.025 * s, 0.05 + 0.15 * s
out = (smallest > size) & (alpha < opacity)
print(f"Haze: {out.sum():,} splats out (all axes over {size * 100:.1f} cm, under {opacity * 100:.0f} % opaque)")
return out
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("src")
ap.add_argument("dst")
for k in ("ceiling", "floor", "haze", "outside"):
ap.add_argument(f"--{k}", type=float, default=0.0)
args = ap.parse_args()
for k in ("ceiling", "floor", "haze", "outside"):
if not 0 <= getattr(args, k) <= 1:
sys.exit(f"--{k} must be between 0 and 1")
header, v = read_ply(args.src)
xyz = np.stack([v["x"], v["y"], v["z"]], 1).astype(np.float64)
alpha = 1 / (1 + np.exp(-v["opacity"].astype(np.float64)))
rgb = 0.5 + C0 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1).astype(np.float64)
lum = rgb @ [0.299, 0.587, 0.114]
room = Room(xyz, alpha)
print(f"Room {room.hi[0] - room.lo[0]:.1f} × {room.hi[1] - room.lo[1]:.1f} m, {len(v):,} splats")
drop, added = np.zeros(len(v), bool), []
if args.ceiling:
d, new = ceiling(room, v, rgb, lum, alpha, args.ceiling)
drop |= d
added.append(new)
if args.floor:
d, new = floor(room, v, rgb, alpha, args.floor)
drop |= d
added.append(new)
if args.haze:
drop |= haze(v, alpha, args.haze)
if args.outside:
m = 1.0 - 0.7 * args.outside
solid_in = room.up[room.within(0) & (alpha > 0.5)]
top = max(np.percentile(solid_in, 99.5), 2.0) if len(solid_in) else 2.5
out = ~room.within(-m) | (room.up < -m) | (room.up > top + m)
print(f"Outside: {out.sum():,} splats more than {m:.2f} m outside the room out")
drop |= out
result = np.concatenate([v[~drop], *added])
write_ply(args.dst, header, result)
print(f"{len(result):,} splats ({drop.sum():,} out, {sum(len(a) for a in added):,} in)")
if __name__ == "__main__":
main()
+556
View File
@@ -0,0 +1,556 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy", "scipy"]
# ///
"""Put several scenes of one recording session together into one big scene.
Usage: uv run combine.py <out folder> <run folder>=<label> [<run folder>=<label> ...]
[--align frame|images|shift|rigid] [--partition view|nearest] [--rooms JSON]
[--colour match|keep]
--rooms: the room of every recording, in the same order, e.g. '["Kitchen", "Kitchen", "Hallway"]' – a room
can be made of several recordings (default: every recording is a room of its own).
All recordings of one session share the Steam Frame's tracking frame, so the rooms are already
roughly in place. Steps:
1. Alignment (--align):
frame as the pipeline delivers it (aligned on the first image of each recording)
images every room fitted to the Frame's tracking over all its images (default)
shift images + a joint shift of the rooms so the overlaps (doors, passages) match
rigid images + a joint shift and slight rotation
2. Who owns which part (--partition):
view per 10 cm cube the recording that saw it from close by and looking at it; the first and
last stretch of a recording along the round of another room's recording (walking in/out)
does not count, so the borders between rooms run through doors (default). Recordings of
the same room simply share it: every part from the recording that saw it best.
nearest the recording whose camera was closest
Either way a cube only goes to a recording that really has something there: at least a quarter of
what the richest one has (splat opacity summed per cube). A camera that "looked at" a place from
behind the sofa does not leave a dark hole there.
2b. Colour (--colour): every training settles on its own brightness, so the same wall can come out a
third darker in one recording than in the next. `match` (default) brings all of them to one
brightness per colour channel, from the cubes they share; `keep` leaves them as they are.
3. Every room is transformed, cut to its own part and appended – one room in memory at a time.
Writes <out>/spirula/scene.ply (all splats), <out>/scene.xrSceneTransform.json (start view of the
room in the middle) and <out>/combine.json (settings and how well the overlaps match).
Lines starting with "@@" are progress markers for the web interface.
"""
import argparse
import itertools
import json
import shutil
import struct
import sys
import time
from pathlib import Path
import numpy as np
from scipy.spatial import cKDTree
from scipy.spatial.transform import Rotation
VOXEL, RADIUS, K = 0.10, 1.5, 64 # ownership: cube size, camera reach, cameras looked at
SHARE = 0.25 # a cube's owner needs at least this share of the richest one's splats
SOLID = 3.0 # summed opacity of a cube something really sits in
GAIN = (0.7, 1.4) # colour matching never changes a recording more than this
C0 = 0.28209479177387814 # spherical harmonics, band 0
BOTH = 1.5 # an overlap: places within this distance of both recordings' cameras
def progress(pct):
print(f"@@ pct {int(pct)}", flush=True)
# ---------------------------------------------------------------- reading
def read_cameras(path: Path):
"""Image names, camera centres, viewing directions (COLMAP: the camera looks along +z)."""
names, centres, fwd = [], [], []
with open(path, "rb") as f:
n, = struct.unpack("<Q", f.read(8))
for _ in range(n):
_, qw, qx, qy, qz, tx, ty, tz, _ = struct.unpack("<i7di", f.read(64))
name = b""
while (ch := f.read(1)) != b"\0":
name += ch
k, = struct.unpack("<Q", f.read(8))
f.seek(24 * k, 1)
R = Rotation.from_quat([qx, qy, qz, qw]).as_matrix()
names.append(name.decode().replace("\\", "/"))
centres.append(-R.T @ np.array([tx, ty, tz]))
fwd.append(R.T[:, 2])
return names, np.array(centres), np.array(fwd)
def read_points(path: Path):
"""Sparse points with a small reprojection error."""
with open(path, "rb") as f:
n, = struct.unpack("<Q", f.read(8))
xyz, err = np.empty((n, 3)), np.empty(n)
for i in range(n):
_, x, y, z, _, _, _, e, k = struct.unpack("<Q3d3BdQ", f.read(51))
f.seek(8 * k, 1)
xyz[i], err[i] = (x, y, z), e
return xyz[err < 2.0]
def read_ply(path: Path):
with open(path, "rb") as f:
header = b""
while not header.endswith(b"end_header\n"):
line = f.readline()
if not line:
sys.exit(f"Not a PLY file: {path}")
header += line
props = [l.split()[-1].decode() for l in header.splitlines() if l.startswith(b"property")]
n = int(next(l for l in header.splitlines() if l.startswith(b"element vertex")).split()[-1])
data = np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
return header, data
def image_time(name: str) -> float:
"""cam0/0123-45.678901.jpg -> 45.678901 (seconds into the recording)."""
return float(name.rsplit("/", 1)[-1].split("-", 1)[1].removesuffix(".jpg"))
# ---------------------------------------------------------------- transforms
class Pose:
"""x -> s R x + t"""
def __init__(self, s=1.0, R=None, t=None):
self.s, self.R, self.t = s, np.eye(3) if R is None else R, np.zeros(3) if t is None else t
def __call__(self, p):
return (self.s * (self.R @ p.T)).T + self.t
def then(self, R, t, centre):
"""Followed by a rigid motion about `centre`."""
return Pose(self.s, R @ self.R, R @ (self.t - centre) + centre + t)
def umeyama(A, B):
"""Similarity that maps the points A onto B (least squares)."""
ma, mb = A.mean(0), B.mean(0)
a, b = A - ma, B - mb
U, S, Vt = np.linalg.svd(b.T @ a / len(A))
D = np.eye(3)
if np.linalg.det(U @ Vt) < 0:
D[2, 2] = -1
R = U @ D @ Vt
s = np.trace(np.diag(S) @ D) / a.var(0).sum()
return Pose(s, R, mb - s * R @ ma)
def quat_mul(a, b):
"""a (w, x, y, z) times every row of b."""
w1, x1, y1, z1 = a
w2, x2, y2, z2 = b.T
return np.stack([w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2, w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,
w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2, w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2], axis=1)
# ---------------------------------------------------------------- overlaps
def overlap_zones(rooms):
"""Sparse points of each pair where both recordings were (doors, passages). The two sides of a
wall are never both within reach of both, so they are not mistaken for the same surface."""
zones = {}
for a, b in itertools.combinations(rooms, 2):
ta, tb = cKDTree(rooms[a]["cams"]), cKDTree(rooms[b]["cams"])
both = lambda p: p[(ta.query(p)[0] < BOTH) & (tb.query(p)[0] < BOTH)]
za, zb = both(rooms[a]["pts"]), both(rooms[b]["pts"])
if len(za) >= 1000 and len(zb) >= 1000:
zones[(a, b)] = (za, zb)
return zones
def offset(pa, pb):
"""Shift that brings the overlap pa onto pb (robust, a few ICP steps) and how many points matched."""
tree = cKDTree(pb)
d, i = tree.query(pa, distance_upper_bound=0.08)
ok = np.isfinite(d)
if ok.sum() < 100:
return None, 0
P, Q, tot = pa[ok], pb[i[ok]], np.zeros(3)
for _ in range(15):
delta = np.median(Q - P, axis=0)
P, tot = P + delta, tot + delta
d2, i2 = tree.query(P, distance_upper_bound=0.05)
ok2 = np.isfinite(d2)
if ok2.sum() < 50:
break
P, Q = P[ok2], pb[i2[ok2]]
return tot, int(ok.sum())
def seams(rooms, zones, poses):
out = {}
for (a, b), (za, zb) in zones.items():
tot, n = offset(poses[a](za), poses[b](zb))
if tot is not None:
out[f"{a}|{b}"] = {"cm": round(float(np.linalg.norm(tot)) * 100, 1), "points": n}
return out
def joint_shift(rooms, zones, poses, fixed):
"""Shift every room (except `fixed`) so all overlaps match best together (least squares)."""
free = [l for l in rooms if l != fixed]
rows, rhs = [], []
for (a, b), (za, zb) in zones.items():
tot, n = offset(poses[a](za), poses[b](zb))
if tot is None:
continue
row = np.zeros(len(free))
if a in free:
row[free.index(a)] += 1 # (x_a + t_a) meets (x_b + t_b) when t_a - t_b = tot
if b in free:
row[free.index(b)] -= 1
rows.append(row * np.sqrt(n))
rhs.append(tot * np.sqrt(n))
if not rows:
return poses
sol, *_ = np.linalg.lstsq(np.array(rows), np.array(rhs), rcond=None)
return {l: poses[l] if l == fixed else poses[l].then(np.eye(3), sol[free.index(l)], np.zeros(3)) for l in rooms}
def joint_rigid(rooms, zones, poses, fixed, centre):
"""Small rotation + shift per room (except `fixed`) over the point pairs of all overlaps."""
free = [l for l in rooms if l != fixed]
rng = np.random.default_rng(1)
sub = {k: (za[rng.choice(len(za), min(len(za), 20000), replace=False)], zb) for k, (za, zb) in zones.items()}
for cut in (0.08, 0.06, 0.05, 0.04, 0.03, 0.03):
rows, rhs = [], []
for (a, b), (za, zb) in sub.items():
pa, pbz = poses[a](za), poses[b](zb)
d, i = cKDTree(pbz).query(pa, distance_upper_bound=cut)
ok = np.isfinite(d)
if ok.sum() < 200:
continue
P, Q = pa[ok] - centre, pbz[i[ok]] - centre
n, w = len(P), 1 / np.sqrt(ok.sum()) # every overlap counts about equally
def cross(X): # rows of -[X]x: (w x X) = cross(X) @ w
z = np.zeros(n)
return -np.stack([np.stack([z, -X[:, 2], X[:, 1]], 1), np.stack([X[:, 2], z, -X[:, 0]], 1),
np.stack([-X[:, 1], X[:, 0], z], 1)], 1)
A = np.zeros((n, 3, 6 * len(free)))
if a in free:
j = free.index(a) * 6
A[:, :, j:j + 3], A[:, :, j + 3:j + 6] = cross(P), np.eye(3)
if b in free:
j = free.index(b) * 6
A[:, :, j:j + 3], A[:, :, j + 3:j + 6] = -cross(Q), -np.eye(3)
rows.append(A.reshape(-1, 6 * len(free)) * w)
rhs.append(-(P - Q).reshape(-1) * w)
if not rows:
break
x, *_ = np.linalg.lstsq(np.vstack(rows), np.concatenate(rhs), rcond=None)
for l in free:
j = free.index(l) * 6
poses[l] = poses[l].then(Rotation.from_rotvec(x[j:j + 3]).as_matrix(), x[j + 3:j + 6], centre)
return poses
# ---------------------------------------------------------------- what every recording has where
def cube_codes(points):
"""10 cm cube of every point as one number (±100 km is plenty)."""
k = np.floor(points / VOXEL).astype(np.int64) + (1 << 20)
return (k[:, 0] << 42) | (k[:, 1] << 21) | k[:, 2]
def contents(clouds):
"""clouds: per recording (cube codes, opacity, rgb) of its splats. Per cube: summed opacity and
opacity-weighted colour of every recording – the cube codes (sorted), cover [cubes, n], colour [cubes, n, 3]."""
codes = [c for c, _, _ in clouds]
table = np.unique(np.concatenate(codes))
cover = np.zeros((len(table), len(clouds)))
colour = np.zeros((len(table), len(clouds), 3))
for i, (c, (_, a, rgb)) in enumerate(zip(codes, clouds)):
j = np.searchsorted(table, c)
cover[:, i] = np.bincount(j, a, len(table))
for ch in range(3):
colour[:, i, ch] = np.bincount(j, a * rgb[:, ch], len(table))
colour /= np.maximum(cover, 1e-9)[:, :, None]
return table, cover, colour
def around(table, cover):
"""Summed over each cube and its 26 neighbours: two recordings' copies of one wall, a centimetre apart,
often fall into neighbouring cubes – compared cube by cube, each would seem to have the wall alone."""
out = np.zeros_like(cover)
for dx, dy, dz in itertools.product((-1, 0, 1), repeat=3):
n = table + ((dx << 42) + (dy << 21) + dz)
j = np.clip(np.searchsorted(table, n), 0, len(table) - 1)
hit = table[j] == n
out[hit] += cover[j[hit]]
return out
def colour_gains(cover, colour, min_cubes=200):
"""Per recording and colour channel a factor that brings all recordings to one brightness: from the
cubes two of them both cover well, least squares on the log ratios, all factors together neutral."""
n = cover.shape[1]
gains = np.ones((n, 3))
if n < 2:
return gains
for ch in range(3):
rows, rhs = [], []
for a, b in itertools.combinations(range(n), 2):
m = (cover[:, a] > SOLID) & (cover[:, b] > SOLID) & (colour[:, a, ch] > 0.02) & (colour[:, b, ch] > 0.02)
if m.sum() < min_cubes:
continue
row = np.zeros(n) # log g_a - log g_b = log(c_b / c_a)
row[a], row[b] = 1, -1
w = np.sqrt(m.sum())
rows.append(row * w)
rhs.append(np.median(np.log(colour[m, b, ch] / colour[m, a, ch])) * w)
if not rows:
continue
rows.append(np.ones(n) * 100) # together neutral: the room keeps its overall brightness
rhs.append(0.0)
x, *_ = np.linalg.lstsq(np.array(rows), np.array(rhs), rcond=None)
gains[:, ch] = np.clip(np.exp(x), *GAIN)
return gains
def apply_gain(v, gain):
"""Colour times `gain` (per channel), the view-dependent part included."""
rest = sorted((p for p in v.dtype.names if p.startswith("f_rest_")), key=lambda p: int(p[7:]))
per = len(rest) // 3 # f_rest: all of red, then green, then blue
for ch in range(3):
g = np.float32(gain[ch])
v[f"f_dc_{ch}"] = (g * (0.5 + C0 * v[f"f_dc_{ch}"]) - 0.5) / C0
for p in rest[ch * per:(ch + 1) * per]:
v[p] *= g
# ---------------------------------------------------------------- ownership
def owners(rooms, poses, partition, have=None):
"""Function: points -> index of the owning recording."""
labels = list(rooms)
cams = [poses[l](rooms[l]["cams"]) for l in labels]
fwd = [(poses[l].R @ rooms[l]["fwd"].T).T for l in labels]
owner = np.concatenate([np.full(len(c), i) for i, c in enumerate(cams)])
def richest(lab, codes):
"""Only a recording with at least SHARE of the richest one's splats in a cube may own it."""
if have is None:
return lab
table, cover = have
j = np.clip(np.searchsorted(table, codes), 0, len(table) - 1)
c = np.where((table[j] == codes)[:, None], cover[j], 0.0)
able = c >= SHARE * c.max(1, keepdims=True)
thin = ~able[np.arange(len(lab)), lab]
lab = lab.copy()
lab[thin] = c[thin].argmax(1)
return lab
if partition == "nearest":
tree = cKDTree(np.vstack(cams))
return lambda p: richest(owner[tree.query(p, workers=-1)[1]], cube_codes(p)), {}
# walking in and out: the first and last stretch of a recording along the path of another room's
# recording ... (recordings of the same room cover the same place on purpose: that is no walking in)
n = len(cams)
room = [rooms[l]["room"] for l in labels]
elsewhere = lambda i, arrays: [a for j, a in enumerate(arrays) if room[j] != room[i]]
def edges(i, others):
if not others:
return np.zeros(len(cams[i]), bool)
inside = cKDTree(np.vstack(others)).query(cams[i])[0] < 0.4
order = np.argsort(rooms[labels[i]]["times"])
flag = np.zeros(len(order), bool)
for seq in (order, order[::-1]):
for k in seq:
if not inside[k]:
break
flag[k] = True
return flag
cand = [edges(i, elsewhere(i, cams)) for i in range(n)]
# ... counts only if another recording covered that place during its own round; otherwise two
# recordings that hand over to each other (one ends where the next starts) would cancel out
transit = []
for i in range(n):
core = [c for c in elsewhere(i, [cams[j][~cand[j]] for j in range(n)]) if len(c)]
transit.append(cand[i] & (cKDTree(np.vstack(core)).query(cams[i])[0] < 0.4) if core
else np.zeros(len(cams[i]), bool))
transit = np.concatenate(transit)
C, F, O = np.vstack(cams)[~transit], np.vstack(fwd)[~transit], owner[~transit]
tree = cKDTree(C)
def own(points):
keys = np.floor(points / VOXEL).astype(np.int64)
uniq, inv = np.unique(keys, axis=0, return_inverse=True)
centre = (uniq + 0.5) * VOXEL
lab = np.empty(len(uniq), dtype=np.int64)
for s in range(0, len(uniq), 200_000):
c = centre[s:s + 200_000]
d, j = tree.query(c, k=K, distance_upper_bound=RADIUS, workers=-1)
ok = np.isfinite(d)
j = np.where(ok, j, 0)
to = c[:, None, :] - C[j]
cos = np.einsum("nkd,nkd->nk", to, F[j]) / np.maximum(np.linalg.norm(to, axis=2), 1e-6)
w = np.where(ok & (cos > 0.3), (RADIUS - np.where(ok, d, RADIUS)) * cos, 0.0)
score = np.stack([(w * (O[j] == r)).sum(1) for r in range(n)], axis=1)
codes = cube_codes(c)
if have is not None: # among the recordings that really have something there
table, cover = have
k = np.clip(np.searchsorted(table, codes), 0, len(table) - 1)
cv = np.where((table[k] == codes)[:, None], cover[k], 0.0)
score = np.where(cv >= SHARE * cv.max(1, keepdims=True), score, -1.0)
best = score.argmax(1)
none = score.max(1) <= 0
if none.any():
best[none] = O[tree.query(c[none], workers=-1)[1]]
lab[s:s + 200_000] = richest(best, codes)
return lab[inv.ravel()]
return own, {labels[i]: int(t.sum()) for i, t in enumerate(np.split(transit, np.cumsum([len(c) for c in cams])[:-1]))}
# ---------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("out")
ap.add_argument("runs", nargs="+", help="run folder=label")
ap.add_argument("--align", choices=("frame", "images", "shift", "rigid"), default="images")
ap.add_argument("--partition", choices=("view", "nearest"), default="view")
ap.add_argument("--rooms", default="", help="JSON list: the room of every recording (default: one each)")
ap.add_argument("--colour", choices=("match", "keep"), default="match")
args = ap.parse_args()
room_of = json.loads(args.rooms) if args.rooms else []
if room_of and (not isinstance(room_of, list) or len(room_of) != len(args.runs)
or not all(isinstance(r, str) and r for r in room_of)):
sys.exit("--rooms needs one room name per recording")
out = Path(args.out)
(out / "spirula").mkdir(parents=True, exist_ok=True)
started = time.time()
print("@@ cphase 0", flush=True)
rooms = {}
for i, arg in enumerate(args.runs):
run, _, label = arg.partition("=")
run = Path(run)
label = label or run.name
if label in rooms:
label = f"{label} ({i + 1})"
aiscene = json.loads((run / "dataset/main.aiscene").read_text())
recorded = {f["path"].removeprefix("images/"): np.array(f["worldFromCam"])[:3, 3] for f in aiscene["frames"]}
names, centres, fwd = read_cameras(run / "dataset/sparse/0/images.bin")
both = [k for k, n in enumerate(names) if n in recorded]
if len(both) < 10:
sys.exit(f"{label}: too few cameras with Frame tracking – was it made with the Arcturus pipeline?")
rooms[label] = {"run": run, "room": room_of[i] if room_of else label, "cams": centres[both], "fwd": fwd[both],
"times": np.array([image_time(names[k]) for k in both]),
"tracked": np.array([recorded[names[k]] for k in both]),
"pts": read_points(run / "dataset/sparse/0/points3D.bin")}
progress(30 * (i + 1) / len(args.runs))
# how far each room's reconstruction is from the Frame's tracking, as delivered and fitted
fit = {l: umeyama(r["cams"], r["tracked"]) for l, r in rooms.items()}
rms = lambda p, r: float(np.sqrt((np.linalg.norm(p(r["cams"]) - r["tracked"], axis=1) ** 2).mean()))
room_info = {l: {"run": str(r["run"]), "room": r["room"], "cameras": len(r["cams"]), "tracking_cm": round(rms(Pose(), r) * 100, 1),
"fitted_cm": round(rms(fit[l], r) * 100, 1)} for l, r in rooms.items()}
# From here on everything is in the fitted frame; `poses` are applied on top of it.
for l, r in rooms.items():
r["cams"], r["pts"] = fit[l](r["cams"]), fit[l](r["pts"])
r["fwd"] = (fit[l].R @ r["fwd"].T).T
poses = {l: Pose() for l in rooms}
if args.align == "frame": # undo the fit: as the pipeline delivered it
poses = {l: Pose(1 / fit[l].s, fit[l].R.T, -fit[l].R.T @ fit[l].t / fit[l].s) for l in rooms}
zones = overlap_zones(rooms)
progress(55)
# one recording session: every room has to overlap with the others (one connected group)
links = {l: set() for l in rooms}
for a, b in zones:
links[a].add(b)
links[b].add(a)
seen, todo = set(), [next(iter(rooms))]
while todo:
l = todo.pop()
if l not in seen:
seen.add(l)
todo += list(links[l])
if len(rooms) > 1 and seen != set(rooms):
lone = ", ".join(sorted(set(rooms) - seen))
print(f"@@ error not_connected {lone}", flush=True)
sys.exit(f"These scenes do not touch the others: {lone}. Only rooms of one recording session "
"(without restarting the Frame in between) share one frame and can be put together.")
hub = max(rooms, key=lambda l: (len(links[l]), sum(len(zones[k][0]) for k in zones if l in k)))
before = seams(rooms, zones, poses)
centre = np.vstack([r["cams"] for r in rooms.values()]).mean(0)
if args.align == "shift":
poses = joint_shift(rooms, zones, poses, hub)
elif args.align == "rigid":
poses = joint_rigid(rooms, zones, poses, hub, centre)
after = seams(rooms, zones, poses)
# from the room's own frame: first the fit, then the poses
final = {l: Pose(poses[l].s * fit[l].s, poses[l].R @ fit[l].R, poses[l](fit[l].t)) for l in rooms}
progress(100)
print("@@ cphase 1", flush=True)
clouds = [] # what every recording has where (one at a time in memory)
for l, r in rooms.items():
_, v = read_ply(r["run"] / "spirula" / "scene.ply")
xyz = final[l](np.stack([v["x"], v["y"], v["z"]], axis=1).astype(np.float64))
rgb = 0.5 + C0 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], axis=1).astype(np.float64)
clouds.append((cube_codes(xyz), 1 / (1 + np.exp(-v["opacity"].astype(np.float64))), rgb.astype(np.float32)))
del v, xyz, rgb
table, cover, colour = contents(clouds)
del clouds
gains = colour_gains(cover, colour) if args.colour == "match" else np.ones((len(rooms), 3))
for l, g in zip(rooms, gains):
if np.abs(g - 1).max() > 0.005:
print(f"{l}: colour × {g[0]:.2f} {g[1]:.2f} {g[2]:.2f}", flush=True)
own, transit = owners(rooms, poses, args.partition, (table, around(table, cover)))
body = out / "spirula" / "scene.body.tmp"
total, kept, header = 0, {}, None
with open(body, "wb") as fb:
for i, (l, r) in enumerate(rooms.items()):
header, v = read_ply(r["run"] / "spirula" / "scene.ply")
xyz = final[l](np.stack([v["x"], v["y"], v["z"]], axis=1).astype(np.float64))
keep = own(xyz) == i
v, xyz = v[keep], xyz[keep]
v["x"], v["y"], v["z"] = xyz.T.astype(np.float32)
apply_gain(v, gains[i])
q = Rotation.from_matrix(final[l].R).as_quat() # (x, y, z, w)
rot = quat_mul(np.array([q[3], q[0], q[1], q[2]]),
np.stack([v["rot_0"], v["rot_1"], v["rot_2"], v["rot_3"]], axis=1).astype(np.float64))
v["rot_0"], v["rot_1"], v["rot_2"], v["rot_3"] = rot.T.astype(np.float32)
for k in ("scale_0", "scale_1", "scale_2"):
v[k] += np.float32(np.log(final[l].s))
v.tofile(fb)
total += len(v)
kept[l] = {"splats": int(keep.size), "kept": int(keep.sum())}
print(f"{l}: {keep.sum():,} of {keep.size:,} splats kept", flush=True)
del v, xyz, rot, keep
progress(100 * (i + 1) / len(rooms))
lines = [b"element vertex %d\n" % total if l.startswith(b"element vertex") else l
for l in header.splitlines(keepends=True)]
with open(out / "spirula" / "scene.ply", "wb") as fo:
fo.write(b"".join(lines))
with open(body, "rb") as fb:
shutil.copyfileobj(fb, fo, 16 << 20)
body.unlink()
# start view: the one of the room in the middle
shutil.copy(rooms[hub]["run"] / "scene.xrSceneTransform.json", out / "scene.xrSceneTransform.json")
for l, g in zip(rooms, gains):
room_info[l].update(kept[l], walk_in_out_cameras=transit.get(l, 0), colour=[round(float(x), 3) for x in g])
result = {"align": args.align, "partition": args.partition, "colour": args.colour, "hub": hub, "splats": total,
"rooms": room_info, "seams_before": before, "seams": after,
"seconds": round(time.time() - started)}
(out / "combine.json").write_text(json.dumps(result, ensure_ascii=False, indent=1))
worst = max(after.values(), key=lambda s: s["cm"]) if after else None
print(f"@@ total {total}", flush=True)
print(f"{total:,} splats; overlaps match within "
+ (f"{np.median([s['cm'] for s in after.values()]):.1f} cm (median), worst {worst['cm']} cm" if worst else "–"),
flush=True)
if __name__ == "__main__":
main()
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
# Put the scenes of one recording session together into one big scene ("tour"):
# all splats for the PC, a smaller version for the Steam Frame.
#
# Usage: scripts/combine.sh <folder name> "<display name>" <run folder>=<label> <run folder>=<label> ...
#
# Environment:
# ALIGN=frame|images|shift|rigid how the rooms are fitted together (default: images, see combine.py)
# PARTITION=view|nearest where the borders between the recordings run (default: view)
# COLOUR=match|keep bring all recordings to one brightness (default: match)
# ROOMS='["Kitchen","Kitchen","Hallway"]' the room of every recording, in order: a room can be made of
# several recordings (default: every recording is a room of its own)
# FRAME_SPLATS=1000000 splats in the Frame version (the full version keeps all)
# FULL=0 skip the full SOG for the PC (the step that needs the most memory)
# FULL_NAME="..." name plate of the full version (default: "<name> (full)")
# PLAN_COMMA=1 decimal comma on the floor plan used as preview
# NO_UPLOAD=1 only compute, don't copy to the Frame
# WORLD_SCALE=87 show a model in Gaussian XR in the size of the original (1:87)
# A previous result of the same name stays until the new one is done.
#
# Lines starting with "@@" are machine-readable progress markers for the web interface.
set -euo pipefail
# Everything in one block: bash reads it completely before it starts, so updating this file
# (git pull, editing) does not affect a run that is already going.
{
HERE=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
ROOT=$(dirname "$HERE")
eval "$(cd "$ROOT" && python3 -m splat_workshop.config --shell)"
slug=${1:?Give a folder name}
name=${2:?Give a display name}
shift 2
[ $# -ge 2 ] || { echo "Give at least two scenes (run folder=label)" >&2; exit 1; }
case "$slug" in ''|.*|*/*) echo "Unusable folder name: $slug" >&2; exit 1 ;; esac
[ -n "$SPLAT_TRANSFORM" ] || { echo "splat-transform not found (npm install -g @playcanvas/splat-transform)" >&2; exit 1; }
out=$RUNS_DIR/$slug
log=$RUNS_DIR/$slug.log
work=$RUNS_DIR/.building
tmp=$work/$slug
stem=$(printf '%s' "$slug" | tr 'A-Z' 'a-z')
frame_splats=${FRAME_SPLATS:-1000000}
# Gaussian XR's worldScale in a scene's transform file, when WORLD_SCALE is set (a model shown in the
# size of the original, e.g. 87 for H0)
world_scale() {
[ -n "${WORLD_SCALE:-}" ] || return 0
python3 - "$1" "$WORLD_SCALE" <<'PY'
import json, sys
path, scale = sys.argv[1], float(sys.argv[2])
data = json.load(open(path))
data["worldScale"] = scale
json.dump(data, open(path, "w"), indent=2)
PY
}
main() {
rm -rf "$tmp"
mkdir -p "$tmp"
trap 'rm -rf "$tmp" "$tmp.old"; rmdir "$work" 2>/dev/null || true' EXIT
trap 'exit 143' TERM; trap 'exit 130' INT; trap 'exit 129' HUP
echo "Putting $# scenes together as \"$name\" (alignment ${ALIGN:-images}, borders ${PARTITION:-view})"
uv run -q "$HERE/combine.py" "$tmp" "$@" --align "${ALIGN:-images}" --partition "${PARTITION:-view}" --colour "${COLOUR:-match}" \
${ROOMS:+--rooms "$ROOMS"}
total=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["splats"])' "$tmp/combine.json")
# Frame version: fewer splats
echo "@@ cphase 2"
src=$tmp/spirula/scene.ply
if [ "$total" -gt "$frame_splats" ]; then
echo "Reducing $total splats to $frame_splats for the Frame ..."
"$SPLAT_TRANSFORM" -w "$src" --decimate "$frame_splats" "$tmp/frame.ply"
src=$tmp/frame.ply
fi
echo "@@ cphase 3"
"$SPLAT_TRANSFORM" -w "$src" "$tmp/scene.sog"
rm -f "$tmp/frame.ply"
if [ "${FULL:-1}" = 1 ]; then
echo "Full version ($total splats) ..."
mkdir -p "$tmp/full"
"$SPLAT_TRANSFORM" -w "$tmp/spirula/scene.ply" "$tmp/full/scene.sog"
cp "$tmp/scene.xrSceneTransform.json" "$tmp/full/scene.xrSceneTransform.json"
fi
# Preview for the gallery: the floor plan of all rooms (labelled by room, not by recording)
plan_args=("$@")
if [ -n "${ROOMS:-}" ]; then
mapfile -t plan_args < <(python3 -c '
import json, sys
for arg, room in zip(sys.argv[2:], json.loads(sys.argv[1])):
print(arg.partition("=")[0] + "=" + room)' "$ROOMS" "$@")
fi
uv run -q "$HERE/floorplan.py" "$tmp/plan.png" "${plan_args[@]}" ${PLAN_COMMA:+--comma} > "$tmp/plan.json"
uv run -q "$HERE/make_gallery.py" --plan "$tmp/plan.png" "$tmp" "$name" "$stem"
if [ -d "$tmp/full" ]; then
uv run -q "$HERE/make_gallery.py" --plan "$tmp/plan.png" "$tmp/full" "${FULL_NAME:-$name (full)}" "$stem-full"
cp "$tmp/full/scene.sog" "$tmp/full/$stem-full.sog"
cp "$tmp/full/scene.xrSceneTransform.json" "$tmp/full/$stem-full.xrSceneTransform.json"
world_scale "$tmp/full/$stem-full.xrSceneTransform.json"
fi
# Replace the previous result only now
if [ -e "$out" ]; then mv "$out" "$tmp.old"; fi
mv "$tmp" "$out"
rm -rf "$tmp.old"
if [ "${NO_UPLOAD:-0}" = 1 ] || [ -z "$FRAME_HOST" ]; then
GALLERY_ONLY=1 LOG="$log" "$HERE/to-frame.sh" "$out" "$name"
echo "@@ done local"
echo "Done: $out"
else
LOG="$log" "$HERE/to-frame.sh" "$out" "$name"
fi
}
mkdir -p "$RUNS_DIR"
main "$@" 2>&1 | tee -a "$log"
exit
}
+317
View File
@@ -0,0 +1,317 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy", "pillow"]
# ///
"""Draw a floor plan (top view) of one or more finished scenes.
Usage: uv run floorplan.py <out.png> <run folder>[=<label>] [<run folder>[=<label>] ...]
The Arcturus pipeline puts every scene into the Steam Frame's tracking frame (metric, Y down).
Recordings made in one session therefore share a frame, and their floor plans line up without
any manual alignment. Per scene the plan shows:
- a colour top view (ceiling cut away, so you see floor and furniture from above)
- walls as bright lines (splats in the wall height band, where they pile up)
- the path the camera was carried along (registered camera positions)
- name and approximate size
Several recordings with the same label are one room (made of several recordings): one name, one size.
The plan is rotated so the walls are axis-aligned.
Seen from above, Y down means +x and +z form a mirrored pair on screen (rows count downwards):
x is drawn right to left, otherwise the plan comes out mirror-inverted.
"""
import argparse
import json
import struct
import sys
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
FONTS = [
"/usr/share/fonts/google-noto/NotoSans-Regular.ttf",
"/usr/share/fonts/google-noto/NotoSans-SemiBold.ttf",
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
]
COLORS = [(124, 156, 255), (255, 184, 107), (70, 211, 181), (255, 107, 129), (200, 160, 255), (240, 225, 120),
(120, 220, 255), (255, 150, 210)]
BG = (15, 17, 21)
SH_C0 = 0.28209479177387814
MAX_POINTS = 1_500_000 # per scene; enough for a sharp plan, keeps memory low
SIZE = 1400 # longest side of the output in pixels
def font(size):
path = next((p for p in FONTS if Path(p).exists()), None)
return ImageFont.truetype(path, size) if path else ImageFont.load_default()
def load_ply(path: Path, rng):
"""x, y, z, rgb of fairly solid splats (memory-mapped, only the needed columns)."""
with open(path, "rb") as f:
props, n = [], 0
while (line := f.readline().decode(errors="replace").strip()) != "end_header":
if line.startswith("element vertex"):
n = int(line.split()[-1])
elif line.startswith("property"):
props.append(line.split()[-1])
offset = f.tell()
dtype = np.dtype([(p, "<f4") for p in props])
data = np.memmap(path, dtype=dtype, mode="r", offset=offset, shape=(n,))
idx = np.sort(rng.choice(n, MAX_POINTS, replace=False)) if n > MAX_POINTS else slice(None)
d = data[idx]
keep = 1 / (1 + np.exp(-d["opacity"].astype(np.float32))) > 0.5
d = d[keep]
xyz = np.stack([d["x"], d["y"], d["z"]], axis=1).astype(np.float32)
rgb = np.clip(0.5 + SH_C0 * np.stack([d["f_dc_0"], d["f_dc_1"], d["f_dc_2"]], axis=1), 0, 1)
return xyz, rgb.astype(np.float32)
def load_cameras(run: Path):
"""Registered camera centres from the aligned COLMAP model (dataset/sparse/0/images.bin), in capture order."""
f = run / "dataset" / "sparse" / "0" / "images.bin"
if not f.exists():
return None
out = []
with open(f, "rb") as fh:
(num,) = struct.unpack("<Q", fh.read(8))
for _ in range(num):
fh.read(4)
qw, qx, qy, qz, tx, ty, tz = struct.unpack("<7d", fh.read(56))
fh.read(4)
name = b""
while (c := fh.read(1)) != b"\0":
name += c
(npts,) = struct.unpack("<Q", fh.read(8))
fh.seek(24 * npts, 1)
if not name.startswith(b"cam0/"):
continue
r = np.array([[1 - 2 * (qy * qy + qz * qz), 2 * (qx * qy - qz * qw), 2 * (qx * qz + qy * qw)],
[2 * (qx * qy + qz * qw), 1 - 2 * (qx * qx + qz * qz), 2 * (qy * qz - qx * qw)],
[2 * (qx * qz - qy * qw), 2 * (qy * qz + qx * qw), 1 - 2 * (qx * qx + qy * qy)]])
out.append((name.decode(), -r.T @ np.array([tx, ty, tz])))
out.sort(key=lambda c: c[0])
return np.array([c[1] for c in out]) if out else None
def heights(y):
"""Floor and ceiling (Y points down): the floor has the largest y."""
return np.percentile(y, 97), np.percentile(y, 3)
def wall_angle(points_xz, bin_size=0.03):
"""Yaw that makes the walls axis-aligned: maximise the peakiness of the x/z histograms."""
best, best_score = 0.0, -1
if len(points_xz) < 50: # no walls found: leave it as it is
return best
for deg in np.arange(0, 90, 0.5):
a = np.radians(deg)
c, s = np.cos(a), np.sin(a)
u = points_xz[:, 0] * c - points_xz[:, 1] * s
v = points_xz[:, 0] * s + points_xz[:, 1] * c
score = 0
for w in (u, v):
h, _ = np.histogram(w, bins=max(10, int((w.max() - w.min()) / bin_size)))
score += float(np.sum(h.astype(np.float64) ** 2)) / len(w) ** 2
if score > best_score:
best, best_score = a, score
return best
def room_box(w, cell=0.05):
"""Bounds of a room from its walls: real walls are dense, floaters (seen through windows/doors) sparse."""
if len(w) < 50:
return None
ij = np.floor(w / cell).astype(np.int64)
_, inv, counts = np.unique(ij, axis=0, return_inverse=True, return_counts=True)
dense = counts[inv.ravel()] >= max(3, np.percentile(counts, 75))
strong = w[dense]
if len(strong) < 50:
strong = w
return np.percentile(strong, 1, axis=0), np.percentile(strong, 99, axis=0)
def rotate(xz, a):
c, s = np.cos(a), np.sin(a)
return np.stack([xz[:, 0] * c - xz[:, 1] * s, xz[:, 0] * s + xz[:, 1] * c], axis=1)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("out")
ap.add_argument("runs", nargs="+", help="run folder, optionally =label")
ap.add_argument("--title", default="")
ap.add_argument("--comma", action="store_true", help="decimal comma in sizes")
ap.add_argument("--ratio", type=float, default=1.0,
help="model scale, e.g. 87 for H0 (1:87): sizes and the scale bar show the original's size")
ap.add_argument("--style", choices=("pretty", "classic"), default="pretty",
help="pretty: colour top view, glowing walls, camera path; classic: glowing point density only")
args = ap.parse_args()
out = Path(args.out)
rng = np.random.default_rng(1)
# All lengths below are in metres of the original: a model (--ratio 87) is measured in its own scale,
# so its walls are looked for 1 to 2 cm above its floor instead of 0.9 to 1.9 m.
m = 1 / args.ratio
scenes = []
for arg in args.runs:
run, _, label = arg.partition("=")
run = Path(run)
xyz, rgb = load_ply(run / "spirula" / "scene.ply", rng)
floor, ceil = heights(xyz[:, 1])
# keep the room: below the ceiling, not below the floor, and drop far-away floaters
lo, hi = np.percentile(xyz[:, [0, 2]], 0.5, axis=0), np.percentile(xyz[:, [0, 2]], 99.5, axis=0)
inside = ((xyz[:, 1] > ceil + 0.25 * m) & (xyz[:, 1] < floor + 0.15 * m)
& np.all(xyz[:, [0, 2]] >= lo - 0.3 * m, axis=1) & np.all(xyz[:, [0, 2]] <= hi + 0.3 * m, axis=1))
if inside.sum() < 100: # e.g. a model drawn without its scale: keep all
inside = np.ones(len(xyz), bool)
up = floor - xyz[:, 1] # height above the floor
walls = inside & (up > 0.9 * m) & (up < 1.9 * m)
cams = load_cameras(run)
s = {"label": label or run.name, "xyz": xyz[inside], "rgb": rgb[inside], "up": up[inside],
"walls": xyz[walls][:, [0, 2]], "cams": [cams] if cams is not None else [], "floor": floor}
same = next((x for x in scenes if x["label"] == s["label"]), None)
if same: # one more recording of this room
for k in ("xyz", "rgb", "up", "walls"):
same[k] = np.concatenate([same[k], s[k]])
same["cams"] += s["cams"]
else:
scenes.append(s)
all_walls = np.vstack([s["walls"] for s in scenes])
sub = all_walls[rng.choice(len(all_walls), min(len(all_walls), 150_000), replace=False)]
angle = wall_angle(sub, 0.03 * m)
for s in scenes:
s["p"] = rotate(s["xyz"][:, [0, 2]], angle)
s["w"] = rotate(s["walls"], angle)
s["c"] = [rotate(c[:, [0, 2]], angle) for c in s["cams"]]
s["box"] = room_box(s["w"], 0.05 * m)
if s["box"] is not None: # keep only this room (plus a margin)
b0, b1 = s["box"][0] - 0.35 * m, s["box"][1] + 0.35 * m
ok = np.all((s["p"] >= b0) & (s["p"] <= b1), axis=1)
s["p"], s["rgb"], s["up"] = s["p"][ok], s["rgb"][ok], s["up"][ok]
okw = np.all((s["w"] >= b0) & (s["w"] <= b1), axis=1)
s["w"] = s["w"][okw]
boxes = [s["box"] for s in scenes if s["box"] is not None]
if boxes:
lo = np.min([b[0] for b in boxes], axis=0) - 0.8 * m
hi = np.max([b[1] for b in boxes], axis=0) + 0.8 * m
else:
allp = np.vstack([s["p"] for s in scenes])
lo, hi = np.percentile(allp, 1, axis=0) - 0.5 * m, np.percentile(allp, 99, axis=0) + 0.5 * m
span = hi - lo
ppm = (SIZE - 160) / span.max() # pixels per metre
W, H = int(span[0] * ppm) + 160, int(span[1] * ppm) + 200
def px(p):
# x from right to left: a top view of a Y-down frame, not its mirror image
return ((hi[0] - p[:, 0]) * ppm + 80).astype(int), ((p[:, 1] - lo[1]) * ppm + 120).astype(int)
if args.style == "classic":
img = classic(scenes, px, W, H)
else:
img = pretty(scenes, px, W, H)
out_img = Image.fromarray((np.clip(img, 0, 1) * 255).astype(np.uint8))
d = ImageDraw.Draw(out_img)
# 3. camera paths, labels and sizes
f_label, f_small, f_title = font(26), font(17), font(30)
for i, s in enumerate(scenes):
col = COLORS[i % len(COLORS)]
for c in (s["c"] if args.style == "pretty" else []): # every recording's path
if len(c) < 2:
continue
x, z = px(c)
pts = list(zip(x.tolist(), z.tolist()))
for k in range(0, len(pts) - 1, 2): # dotted line
d.line([pts[k], pts[k + 1]], fill=col + (255,), width=2)
d.ellipse((pts[0][0] - 6, pts[0][1] - 6, pts[0][0] + 6, pts[0][1] + 6), outline=col, width=3)
if s["box"] is not None:
(bx0, bz0), (bx1, bz1) = s["box"]
cx, cz = px(np.array([[(bx0 + bx1) / 2, (bz0 + bz1) / 2]]))
size = f"~ {(bx1 - bx0) * args.ratio:.1f} × {(bz1 - bz0) * args.ratio:.1f} m".replace(".", "," if args.comma else ".")
for txt, fnt, dy in ((s["label"], f_label, -16), (size, f_small, 16)):
tw = d.textlength(txt, font=fnt)
d.text((cx[0] - tw / 2 + 1, cz[0] + dy - 13 + 1), txt, font=fnt, fill=(0, 0, 0))
d.text((cx[0] - tw / 2, cz[0] + dy - 13), txt, font=fnt, fill=(255, 255, 255) if dy < 0 else (210, 214, 224))
# scale bar: a round length of the original (for a model: of the real thing), about 150 px long
real_ppm = ppm / args.ratio # pixels per metre of the original
length = min((0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000), key=lambda l: abs(l * real_ppm - 150))
bar = length * real_ppm
text = (f"{length * 100:g} cm" if length < 1 else f"{length:g} m")
d.line((W - 60 - bar, H - 40, W - 60, H - 40), fill=(230, 230, 230), width=3)
for xx in (W - 60 - bar, W - 60):
d.line((xx, H - 47, xx, H - 33), fill=(230, 230, 230), width=3)
tw = d.textlength(text, font=f_small)
d.text((W - 60 - bar / 2 - tw / 2, H - 72), text, font=f_small, fill=(230, 230, 230))
if args.ratio != 1:
ratio = f"1:{args.ratio:g}".replace(".", "," if args.comma else ".")
d.text((W - 60 - bar - 20 - d.textlength(ratio, font=f_small), H - 50), ratio, font=f_small, fill=(230, 230, 230))
if args.title:
d.text((40, 32), args.title, font=f_title, fill=(235, 238, 245))
out_img.save(out)
info = [{"label": s["label"], "size": [round(float(v) * args.ratio, 2) for v in (s["box"][1] - s["box"][0])]
if s["box"] is not None else None} for s in scenes]
print(json.dumps({"angle": round(float(np.degrees(angle)), 1), "scenes": info, "ratio": args.ratio,
"pixels_per_metre": round(real_ppm, 4)}))
def classic(scenes, px, W, H):
"""Glowing point density of everything between knee and head height, one colour per scene."""
img = np.zeros((H, W, 3), np.float32) + np.array(BG, np.float32) / 255
for i, s in enumerate(scenes):
col = np.array(COLORS[i % len(COLORS)], np.float32) / 255
lo_h, hi_h = np.percentile(s["up"], 15), np.percentile(s["up"], 85)
q = s["p"][(s["up"] > lo_h) & (s["up"] < hi_h)]
x, z = px(q)
ok = (x >= 0) & (x < W) & (z >= 0) & (z < H)
h = np.zeros((H, W), np.float32)
np.add.at(h, (z[ok], x[ok]), 1)
ref = np.percentile(h[h > 0], 98) if (h > 0).any() else 1
h = np.clip(np.log1p(h) / np.log1p(ref), 0, 1) ** 0.75
h = np.asarray(Image.fromarray((h * 255).astype(np.uint8)).filter(ImageFilter.MaxFilter(3)), np.float32) / 255
img = np.maximum(img, h[..., None] * col)
return img
def pretty(scenes, px, W, H):
# 1. colour top view: per pixel the highest splat (z-buffer on the height above floor)
top = np.zeros((H, W, 3), np.float32)
level = np.full((H, W), -9.0, np.float32)
for s in scenes:
x, z = px(s["p"])
ok = (x >= 0) & (x < W) & (z >= 0) & (z < H)
order = np.argsort(s["up"][ok])
xs, zs, ups, cols = x[ok][order], z[ok][order], s["up"][ok][order], s["rgb"][ok][order]
top[zs, xs] = cols # later (= higher) points win
level[zs, xs] = np.maximum(level[zs, xs], ups)
covered = level > -9
img = np.zeros((H, W, 3), np.float32) + np.array(BG, np.float32) / 255
# soften: fill small gaps, slightly darken so the walls stand out
top_img = Image.fromarray((top * 255).astype(np.uint8)).filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.GaussianBlur(1.2))
mask = Image.fromarray((covered * 255).astype(np.uint8)).filter(ImageFilter.MaxFilter(5)).filter(ImageFilter.GaussianBlur(1.5))
top_arr = (np.asarray(top_img, np.float32) / 255) ** 0.7 * 0.85 # lift dark interiors a little
m = np.asarray(mask, np.float32)[..., None] / 255
img = img * (1 - m) + top_arr * m
# 2. walls: density of splats in the wall band, drawn as glowing lines in the scene colour
for i, s in enumerate(scenes):
col = np.array(COLORS[i % len(COLORS)] if len(scenes) > 1 else (235, 240, 255), np.float32) / 255
x, z = px(s["w"])
ok = (x >= 0) & (x < W) & (z >= 0) & (z < H)
h = np.zeros((H, W), np.float32)
np.add.at(h, (z[ok], x[ok]), 1)
h = np.clip(np.log1p(h) / np.log1p(np.percentile(h[h > 0], 97) if (h > 0).any() else 1), 0, 1)
glow = np.asarray(Image.fromarray((h * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(3)), np.float32) / 255
core = h ** 0.8
a = np.clip(core + glow * 0.8, 0, 1)[..., None]
img = img * (1 - a * 0.9) + col * a * 0.9 + col * glow[..., None] * 0.25
return img
if __name__ == "__main__":
main()
+167
View File
@@ -0,0 +1,167 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy", "scipy"]
# ///
"""EXPERIMENTAL, not wired into the interface yet (0.4): splats in space the cameras looked through.
For every (sampled) image: the SfM points it saw give a coarse depth map (per cell the nearest seen
surface). A splat that projects into such a cell clearly in front of that surface sat in the line of sight
of a camera that saw something behind it: free space. Removed when enough images say so.
Usage: freespace.py <scene.ply> <out.ply> <run>[,<run>...] [--every N] [--strength S] [--solid A]
<run> the recordings the scene was made of (their sparse model and the Frame's tracking); the
scene must be in their fitted frame, as combine.py makes it (alignment "images")
--solid A where the SfM points say nothing (white walls and ceilings), use the splats more opaque than A
First try on a real living room (15.10.2026): only 0.4 % of the splats found, mostly faint ones. The SfM
points – the only reliable "something is here" – are missing exactly on white surfaces, and the dark veil
under a white ceiling hangs closer to it (10–35 cm) than this can tell apart; scripts/clean.py --ceiling
is the tool for that.
"""
import argparse
import json
import struct
import sys
from pathlib import Path
import numpy as np
from scipy.spatial.transform import Rotation
sys.path.insert(0, str(Path(__file__).resolve().parent))
import combine as C # noqa: E402
NP = {0: 3, 1: 4, 2: 4, 3: 5, 4: 8, 5: 8, 6: 12, 7: 5, 8: 4, 9: 5, 10: 12}
GRID = 48 # depth cells per image side
def read_cams(path):
out = {}
with open(path, "rb") as f:
n, = struct.unpack("<Q", f.read(8))
for _ in range(n):
cid, model, w, h = struct.unpack("<iiQQ", f.read(24))
out[cid] = (model, w, h, np.array(struct.unpack("<%dd" % NP[model], f.read(8 * NP[model]))))
return out
def read_images(path):
out = []
with open(path, "rb") as f:
n, = struct.unpack("<Q", f.read(8))
for _ in range(n):
_, qw, qx, qy, qz, tx, ty, tz, cid = struct.unpack("<i7di", f.read(64))
name = b""
while (ch := f.read(1)) != b"\0":
name += ch
k, = struct.unpack("<Q", f.read(8))
obs = np.frombuffer(f.read(24 * k), dtype=np.dtype([("x", "<f8"), ("y", "<f8"), ("id", "<i8")]))
R = Rotation.from_quat([qx, qy, qz, qw]).as_matrix()
out.append((name.decode(), R, np.array([tx, ty, tz]), cid, obs[obs["id"] >= 0].copy()))
return out
def read_points3d(path):
ids, xyz = [], []
with open(path, "rb") as f:
n, = struct.unpack("<Q", f.read(8))
for _ in range(n):
pid, x, y, z, _, _, _, e, k = struct.unpack("<Q3d3BdQ", f.read(51))
f.seek(8 * k, 1)
ids.append(pid)
xyz.append((x, y, z))
ids = np.array(ids)
table = np.full(ids.max() + 1, -1)
table[ids] = np.arange(len(ids))
return table, np.array(xyz)
def project(cam, pc):
"""Camera coordinates -> pixels (OPENCV_FISHEYE or pinhole); valid only for z > 0."""
model, w, h, p = cam
x, y = pc[:, 0] / pc[:, 2], pc[:, 1] / pc[:, 2]
if model == 5:
fx, fy, cx, cy, k1, k2, k3, k4 = p
r = np.sqrt(x * x + y * y)
th = np.arctan(r)
thd = th * (1 + k1 * th**2 + k2 * th**4 + k3 * th**6 + k4 * th**8)
s = np.where(r > 1e-9, thd / np.maximum(r, 1e-9), 1.0)
return fx * x * s + cx, fy * y * s + cy
fx, fy, cx, cy = (p[0], p[0], p[1], p[2]) if model in (0, 2, 3) else p[:4]
return fx * x + cx, fy * y + cy
def main():
ap = argparse.ArgumentParser()
ap.add_argument("src")
ap.add_argument("dst")
ap.add_argument("runs")
ap.add_argument("--every", type=int, default=4)
ap.add_argument("--strength", type=float, default=0.5)
ap.add_argument("--solid", type=float, default=0.0)
args = ap.parse_args()
header, v = C.read_ply(Path(args.src))
P = np.stack([v["x"], v["y"], v["z"]], 1).astype(np.float64)
A = 1 / (1 + np.exp(-v["opacity"].astype(np.float64)))
free = np.zeros(len(v), np.int32)
seen = np.zeros(len(v), np.int32)
for run in args.runs.split(","):
run = Path(run)
sp = run / "dataset/sparse/0"
cams, imgs = read_cams(sp / "cameras.bin"), read_images(sp / "images.bin")
table, X = read_points3d(sp / "points3D.bin")
# the room's frame: fitted to the Frame's tracking, as combine.py does (alignment "images")
ai = json.loads((run / "dataset/main.aiscene").read_text())
rec = {f["path"].removeprefix("images/"): np.array(f["worldFromCam"])[:3, 3] for f in ai["frames"]}
names = [i[0].replace("\\", "/") for i in imgs]
centres = np.array([-i[1].T @ i[2] for i in imgs])
both = [k for k, nm in enumerate(names) if nm in rec]
fit = C.umeyama(centres[both], np.array([rec[names[k]] for k in both]))
# splats into this recording's own frame (inverse of the fit), cameras stay as they are
Q = (fit.R.T @ ((P - fit.t) / fit.s).T).T
for name, R, t, cid, obs in imgs[::args.every]:
cam = cams[cid]
_, w, h, _ = cam
pts = X[table[obs["id"]]]
pc = (R @ pts.T).T + t
ok = pc[:, 2] > 0.05
cx = np.clip((obs["x"][ok] / w * GRID).astype(int), 0, GRID - 1)
cy = np.clip((obs["y"][ok] / h * GRID).astype(int), 0, GRID - 1)
depth = np.full(GRID * GRID, np.inf)
np.minimum.at(depth, cy * GRID + cx, pc[ok, 2])
sc = (R @ Q.T).T + t
front = sc[:, 2] > 0.1
if args.solid: # where SfM saw nothing (a white ceiling): the solid splats
so = front & (A > args.solid)
us, vs = project(cam, sc[so])
ins = (us >= 0) & (us < w) & (vs >= 0) & (vs < h)
cs = (np.clip((vs[ins] / h * GRID).astype(int), 0, GRID - 1) * GRID
+ np.clip((us[ins] / w * GRID).astype(int), 0, GRID - 1))
sd = np.full(GRID * GRID, np.inf)
np.minimum.at(sd, cs, sc[so][ins, 2])
depth = np.where(np.isfinite(depth), depth, sd)
u, vv = project(cam, sc[front])
inside = (u >= 0) & (u < w) & (vv >= 0) & (vv < h)
idx = np.flatnonzero(front)[inside]
cell = (np.clip((vv[inside] / h * GRID).astype(int), 0, GRID - 1) * GRID
+ np.clip((u[inside] / w * GRID).astype(int), 0, GRID - 1))
d = depth[cell]
known = np.isfinite(d)
z = sc[idx, 2]
seen[idx[known]] += 1
margin = 0.15 + 0.1 * d[known]
free[idx[known][z[known] < d[known] - margin]] += 1
print(run.name, "images used:", len(imgs[::args.every]), flush=True)
s = args.strength
need = int(round(6 - 4 * s)) # images that looked through it
frac = 0.7 - 0.3 * s # of those that saw the place at all
drop = (free >= need) & (free >= frac * np.maximum(seen, 1)) & (A < 0.9)
print(f"free-space splats: {drop.sum():,} of {len(v):,} ({100 * drop.mean():.1f} %), "
f"opacity median {np.median(A[drop]) if drop.any() else 0:.2f}")
lines = [b"element vertex %d\n" % (~drop).sum() if l.startswith(b"element vertex") else l
for l in header.splitlines(keepends=True)]
with open(args.dst, "wb") as f:
f.write(b"".join(lines))
v[~drop].tofile(f)
if __name__ == "__main__":
main()
+27 -1
View File
@@ -5,6 +5,8 @@
"""Create the preview image (512x512) and name plate (358x86) for the Gaussian XR gallery.
Usage: uv run make_gallery.py <run folder> <pipeline log> <display name> <file stem>
uv run make_gallery.py --plan <floor plan.png> <run folder> <display name> <file stem>
uv run make_gallery.py --label <name plate.png> <text>
Reads the initial view from the log ("Gaussian XR initial view: cam0/... Display rotation X°")
and writes <file stem>.gallery.png and <file stem>.sceneinfo.png into the run folder.
The look matches the example scenes that ship with Gaussian XR.
@@ -82,12 +84,36 @@ def label(text: str, out: Path) -> None:
img.save(out)
def from_plan(plan: Path, out: Path) -> None:
"""Preview of a combined scene: its floor plan, centred on a square."""
img = Image.open(plan).convert("RGB")
side = max(img.size)
square = Image.new("RGB", (side, side), img.getpixel((5, 5)))
square.paste(img, ((side - img.width) // 2, (side - img.height) // 2))
square.resize((512, 512), Image.LANCZOS).convert("RGBA").save(out)
def main() -> None:
if len(sys.argv) == 4 and sys.argv[1] == "--label":
label(sys.argv[3], Path(sys.argv[2]))
return
if len(sys.argv) == 6 and sys.argv[1] == "--plan":
plan, run, name, stem = Path(sys.argv[2]), Path(sys.argv[3]), sys.argv[4], sys.argv[5]
from_plan(plan, run / f"{stem}.gallery.png")
label(name, run / f"{stem}.sceneinfo.png")
print(f"Preview image from the floor plan, name plate \"{name}\"")
return
if len(sys.argv) != 5:
sys.exit(__doc__)
run, log, name, stem = Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3], sys.argv[4]
preview = run / f"{stem}.gallery.png"
if preview.exists() and not any(run.glob("dataset/images/cam0/*.jpg")):
# Training images were cleaned up: keep the existing preview, only renew the name plate
label(name, run / f"{stem}.sceneinfo.png")
print(f"Name plate \"{name}\" (preview image kept)")
return
image, rotation = initial_view(run, log)
gallery(image, rotation, run / f"{stem}.gallery.png")
gallery(image, rotation, preview)
label(name, run / f"{stem}.sceneinfo.png")
print(f"Preview image from {image.name} (rotation {rotation:g}°), name plate \"{name}\"")
+82 -4
View File
@@ -13,13 +13,21 @@
# max ~8 image pairs per second of video (100-500), full resolution, up to 3 M splats, 50k steps
# FRAMES, SCALE, MAXG, ITER override single preset values
# NO_UPLOAD=1 only compute, don't copy to the Frame
# FRESH=1 discard an existing run of the same name first
# PART=120-245 only this stretch of the recording (seconds): cut losslessly with the original
# timestamps (the pipeline matches the images to the poses by time) into
# runs/.parts/<name>.mp4, which goes again once the scene is done. A long recording in
# several parts gets up to 500 image pairs per part.
# FRESH=1 start an existing run of the same name over; its result stays until the new one
# is done and comes back if the new run fails or is cancelled
# Interrupted? Run the same command again with --resume.
# Paths and the Frame address come from ~/.config/splat-workshop/config.toml
# (or PIPELINE_DIR, SPIRULA_BIN, SPLAT_TRANSFORM, RECORDINGS_DIR, RUNS_DIR, FRAME_HOST).
#
# Lines starting with "@@" are machine-readable progress markers for the web interface.
set -euo pipefail
# Everything in one block: bash reads it completely before it starts, so updating this file
# (git pull, editing) does not affect a run that is already going.
{
HERE=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
ROOT=$(dirname "$HERE")
@@ -57,12 +65,74 @@ fi
out=$RUNS_DIR/$slug
log=$RUNS_DIR/$slug.log
mkdir -p "$RUNS_DIR"
if [ "${FRESH:-0}" = 1 ]; then rm -rf "$out" "$log"; fi
# A stretch of the recording: cut it once (the same file again for --resume)
secs=
if [ -n "${PART:-}" ]; then
start=${PART%-*}; end=${PART#*-}
[[ "$start" =~ ^[0-9]+(\.[0-9]+)?$ && "$end" =~ ^[0-9]+(\.[0-9]+)?$ ]] \
&& awk -v a="$start" -v b="$end" 'BEGIN { exit !(b > a) }' \
|| { echo "PART must be <start>-<end> in seconds, e.g. 120-245" >&2; exit 1; }
cut=$RUNS_DIR/.parts/$slug.mp4
if [ ! -f "$cut" ]; then
mkdir -p "$RUNS_DIR/.parts"
echo "Cutting $start-$end s out of $(basename "$in") ..."
ffmpeg -v error -y -copyts -ss "$start" -i "$in" -to "$end" -map 0 -c copy -copy_unknown "$cut.tmp.mp4"
mv "$cut.tmp.mp4" "$cut"
fi
in=$cut
secs=$(awk -v a="$start" -v b="$end" 'BEGIN { print b - a }')
fi
# FRESH: set the previous result aside, delete it only once the new one is there
prev=$RUNS_DIR/.previous/$slug
restore_previous() {
[ -d "$prev/run" ] && [ ! -f "$out/scene.sog" ] || return 0
if [ -e "$out" ]; then mv "$out" "$prev/failed"; fi
mv "$prev/run" "$out"
if [ -f "$prev/log" ]; then mv "$prev/log" "$log"; fi
# The web interface keeps the settings of the previous run in "previous"
python3 - "$RUNS_DIR/$slug.ui.json" <<'PY' || true
import json, sys
try:
meta = json.load(open(sys.argv[1]))
except (OSError, ValueError):
sys.exit()
if isinstance(meta.get("previous"), dict):
json.dump(meta["previous"], open(sys.argv[1], "w"), ensure_ascii=False)
PY
echo "The previous result of \"$name\" was kept."
rm -rf "$prev"
rmdir "$RUNS_DIR/.previous" 2>/dev/null || true
}
if [ "${FRESH:-0}" = 1 ]; then
restore_previous # left over from an earlier run that never finished (e.g. power cut)
rm -rf "$prev"
if [ -f "$out/scene.sog" ]; then
mkdir -p "$prev"
mv "$out" "$prev/run"
if [ -f "$log" ]; then mv "$log" "$prev/log"; fi
trap restore_previous EXIT
trap 'exit 143' TERM; trap 'exit 130' INT; trap 'exit 129' HUP
else
rm -rf "$out" "$log"
fi
fi
# The pipeline resumes finished stages only: an interrupted training run blocks --resume.
# Then start the training over; images and camera positions are reused.
manifest=$out/spirula/run.json
if [[ " $* " == *" --resume "* ]] && [ -f "$manifest" ] && ! grep -q '"status": *"complete"' "$manifest"; then
ckpt=$(ls -d "$out"/spirula/training/step-*.ckpt 2>/dev/null | tail -1)
echo "Training was interrupted${ckpt:+ (last checkpoint: ${ckpt##*/step-})}; the pipeline cannot continue it." | tee -a "$log"
echo "Restarting the training, images and camera positions are reused." | tee -a "$log"
rm -rf "$out/spirula"
fi
# Image pairs from the duration: <per second>, clamped to the pipeline limits 100..500
pairs() {
local secs
secs=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$in")
local secs=$secs
[ -n "$secs" ] || secs=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$in")
awk -v s="$secs" -v r="$1" 'BEGIN { f = int(s * r + 0.5); if (f < 100) f = 100; if (f > 500) f = 500; print f }'
}
@@ -84,6 +154,12 @@ SS_LANG=en uv run av1_to_3dgs.py "$in" "$out" \
--splat-transform "$SPLAT_TRANSFORM" \
"${quality[@]}" "$@" 2>&1 | tee -a "$log"
[ -f "$out/scene.sog" ] || { echo "No scene.sog was produced, see $log" >&2; exit 1; }
if [ -n "${PART:-}" ]; then
rm -f "$cut" # cut again from the recording when needed
rmdir "$RUNS_DIR/.parts" 2>/dev/null || true
fi
rm -rf "$prev"
rmdir "$RUNS_DIR/.previous" 2>/dev/null || true
# 3. Gallery files, then copy to the Frame (to-frame.sh also works on its own)
if [ "${NO_UPLOAD:-0}" = 1 ] || [ -z "$FRAME_HOST" ]; then
@@ -93,3 +169,5 @@ if [ "${NO_UPLOAD:-0}" = 1 ] || [ -z "$FRAME_HOST" ]; then
exit 0
fi
LOG="$log" "$HERE/to-frame.sh" "$out" "$name"
exit
}
+44 -9
View File
@@ -5,31 +5,52 @@
# Usage: scripts/to-frame.sh <run folder> "<display name>"
# <run folder> runs/<name> or just <name>
# GALLERY_ONLY=1 only create the gallery files (preview image, name plate) in the run folder
# TARGET=NN_name use this scene folder on the Frame (default: same name, or next free number from 20)
# TARGET=NN_name use this scene folder on the Frame (default: same name, or next free number from 20;
# a folder of that name in Gaussian XR's own gallery folder is moved over)
# STEM=name file stem (default: folder name in lower case)
# SLUG=name name for a new scene folder on the Frame (default: the run folder's name)
# LOG=file pipeline log with the initial view (default: runs/<folder>.log)
set -euo pipefail
# Everything in one block: bash reads it completely before it starts, so updating this file
# (git pull, editing) does not affect a run that is already going.
{
HERE=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
ROOT=$(dirname "$HERE")
eval "$(cd "$ROOT" && python3 -m splat_workshop.config --shell)"
FRAME_SCENES=.local/share/Steam/steamapps/common/ArcturusVisionGaussianXR/Scenes
FRAME_BACKUP=Documents/GaussianSplats
# Gaussian XR's folder for personal scenes – Steam updates leave it alone. Its own gallery folder
# (FRAME_STEAM) holds the examples, and scenes copied by Splat Workshop 0.2.3 and older.
FRAME_SCENES=Documents/GaussianXR
FRAME_STEAM=.local/share/Steam/steamapps/common/ArcturusVisionGaussianXR/Scenes
run=${1:?Give a run folder}
name=${2:?Give a display name}
[ -d "$run" ] || run=$RUNS_DIR/$run
out=$(realpath "$run")
slug=$(basename "$out")
slug=${SLUG:-$(basename "$out")}
[ -f "$out/scene.sog" ] || { echo "No scene.sog in $out" >&2; exit 1; }
log=${LOG:-$RUNS_DIR/$slug.log}
[ -f "$log" ] || log=$(grep -ls "Gaussian XR initial view" "$RUNS_DIR"/*.log 2>/dev/null | xargs -r grep -l "$out/" | head -1)
[ -f "$log" ] || { echo "No pipeline log found for $slug (set LOG=...)" >&2; exit 1; }
# Gaussian XR's worldScale in a scene's transform file, when WORLD_SCALE is set (a model shown in the
# size of the original, e.g. 87 for H0)
world_scale() {
[ -n "${WORLD_SCALE:-}" ] || return 0
python3 - "$1" "$WORLD_SCALE" <<'PY'
import json, sys
path, scale = sys.argv[1], float(sys.argv[2])
data = json.load(open(path))
data["worldScale"] = scale
json.dump(data, open(path, "w"), indent=2)
PY
}
# Gallery files: <stem>.sog / .xrSceneTransform.json / .gallery.png / .sceneinfo.png
stem=${STEM:-$(printf '%s' "$slug" | tr 'A-Z' 'a-z')}
cp "$out/scene.sog" "$out/$stem.sog"
cp "$out/scene.xrSceneTransform.json" "$out/$stem.xrSceneTransform.json"
world_scale "$out/$stem.xrSceneTransform.json"
uv run -q "$HERE/make_gallery.py" "$out" "$log" "$name" "$stem"
[ "${GALLERY_ONLY:-0}" = 1 ] && exit 0
@@ -38,15 +59,29 @@ uv run -q "$HERE/make_gallery.py" "$out" "$log" "$name" "$stem"
echo "@@ phase upload"
echo "Copying to the Frame ..."
# Reuse an existing folder of the same name, otherwise the next free number from 20
# (numbers are counted in both folders, so a scene never has the same number as another in the gallery)
folder=${TARGET:-$(ssh -o ConnectTimeout=8 "$FRAME_HOST" "
existing=\$(ls -d $FRAME_SCENES/[0-9][0-9]_$slug 2>/dev/null | head -1)
found() { ls -d $FRAME_SCENES/\$1 $FRAME_STEAM/\$1 2>/dev/null | head -1; }
existing=\$(found '[0-9][0-9]_$slug')
if [ -n \"\$existing\" ]; then basename \"\$existing\"; exit; fi
n=20; while ls -d $FRAME_SCENES/\${n}_* >/dev/null 2>&1; do n=\$((n+1)); done
n=20; while [ -n \"\$(found \${n}_\*)\" ]; do n=\$((n+1)); done
echo \${n}_$slug")}
ssh -o ConnectTimeout=8 "$FRAME_HOST" "mkdir -p $FRAME_BACKUP/$folder $FRAME_SCENES/$folder"
scp -q "$out/$stem".{sog,xrSceneTransform.json,gallery.png,sceneinfo.png} "$FRAME_HOST:$FRAME_BACKUP/$folder/"
ssh "$FRAME_HOST" "cp $FRAME_BACKUP/$folder/$stem.* $FRAME_SCENES/$folder/ && chmod 755 $FRAME_SCENES/$folder/*"
case "$folder" in [0-9][0-9]_?*) ;; *) echo "Unusable folder on the Frame: '$folder'" >&2; exit 1 ;; esac
case "$folder" in */*|*..*) echo "Unusable folder on the Frame: '$folder'" >&2; exit 1 ;; esac
# Straight into the gallery – everything is on the PC, copying again restores a scene.
# Files of this scene under an earlier name (renamed in Splat Workshop) go first, so the folder holds one scene.
# A copy made by 0.2.3 or older in Gaussian XR's own gallery folder moves to the personal one.
ssh -o ConnectTimeout=8 "$FRAME_HOST" "mkdir -p $FRAME_SCENES && \
if [ -d $FRAME_STEAM/$folder ] && [ ! -e $FRAME_SCENES/$folder ]; then mv $FRAME_STEAM/$folder $FRAME_SCENES/; fi && \
rm -rf $FRAME_STEAM/$folder && mkdir -p $FRAME_SCENES/$folder && cd $FRAME_SCENES/$folder && \
for f in *.sog *.xrSceneTransform.json *.gallery.png *.sceneinfo.png; do
case \$f in $stem.*) ;; *) rm -f -- \"\$f\" ;; esac
done"
scp -q "$out/$stem".{sog,xrSceneTransform.json,gallery.png,sceneinfo.png} "$FRAME_HOST:$FRAME_SCENES/$folder/"
ssh "$FRAME_HOST" "chmod 755 $FRAME_SCENES/$folder/*"
echo "@@ done frame $folder"
echo
echo "Done: \"$name\" is in the Gaussian XR gallery ($folder)."
echo "Restart Gaussian XR to see it."
exit
}
+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
# Versions of a finished scene or tour: fewer splats for the Frame, all (or a limit) for the PC,
# optionally shown in the original's size (models). Each version is a complete scene in
# <run folder>/versions/<key>/ and can be copied to the Frame next to the others.
#
# Usage: scripts/versions.sh <run folder> "<key>|<splats>|<scale>|<name plate>[|<clean-up>]" ...
# <key> folder name of the version, e.g. 1m, 0.75m, all, 1m-1x87
# <splats> number of splats, or "all"
# <scale> 1, or e.g. 87: Gaussian XR shows it in the original's size (worldScale 87)
# <name plate> text on the name plate in the gallery, e.g. "Flat · 1 M"
# <clean-up> optional, e.g. "ceiling=0.5,haze=0.3": cleaned up first (scripts/clean.py; ceiling,
# floor, haze, outside, each 0 to 1), then reduced
# The splats come from <run folder>/spirula/scene.ply. Lines starting with "@@" are progress markers.
set -euo pipefail
# Everything in one block: bash reads it completely before it starts, so updating this file
# (git pull, editing) does not affect a run that is already going.
{
HERE=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
ROOT=$(dirname "$HERE")
eval "$(cd "$ROOT" && python3 -m splat_workshop.config --shell)"
out=$(realpath "${1:?Give a run folder}")
shift
[ $# -ge 1 ] || { echo "Give at least one version" >&2; exit 1; }
src=$out/spirula/scene.ply
[ -f "$src" ] || { echo "No spirula/scene.ply in $out" >&2; exit 1; }
[ -n "$SPLAT_TRANSFORM" ] || { echo "splat-transform not found" >&2; exit 1; }
slug=$(basename "$out")
preview=$(ls "$out"/*.gallery.png 2>/dev/null | head -1 || true)
total=$(python3 - "$src" <<'PY'
import sys
with open(sys.argv[1], "rb") as f:
for line in f:
if line.startswith(b"element vertex"):
print(int(line.split()[-1])); break
PY
)
echo "Making $# version(s) of $slug ($total splats)"
i=0
for spec in "$@"; do
i=$((i + 1))
IFS='|' read -r key splats scale plate clean <<< "$spec"
case "$key" in ''|.*|*/*) echo "Unusable version name: $key" >&2; exit 1 ;; esac
echo "@@ vstep $i $# $key"
dir=$out/versions/$key
tmp=$dir.tmp
rm -rf "$tmp"
mkdir -p "$tmp"
trap 'rm -rf "$tmp"' EXIT
from=$src
have=$total
if [ -n "${clean:-}" ]; then
args=()
IFS=',' read -ra parts <<< "$clean"
for kv in "${parts[@]}"; do
k=${kv%%=*} v=${kv#*=}
case "$k" in ceiling|floor|haze|outside) ;; *) echo "Unknown clean-up: $k" >&2; exit 1 ;; esac
[[ "$v" =~ ^(0(\.[0-9]+)?|1(\.0+)?)$ ]] || { echo "Clean-up strength must be 0 to 1: $kv" >&2; exit 1; }
args+=("--$k" "$v")
done
echo "$key: cleaning up ($clean)"
uv run -q "$HERE/clean.py" "$src" "$tmp/clean.ply" "${args[@]}"
from=$tmp/clean.ply
have=$(python3 -c 'import sys
with open(sys.argv[1], "rb") as f:
print(next(int(l.split()[-1]) for l in f if l.startswith(b"element vertex")))' "$from")
fi
if [ "$splats" = all ] || [ "$splats" -ge "$have" ]; then
echo "$key: all $have splats"
"$SPLAT_TRANSFORM" -w "$from" "$tmp/scene.sog"
made=$have
else
echo "$key: reducing $have to $splats splats"
"$SPLAT_TRANSFORM" -w "$from" --decimate "$splats" "$tmp/reduced.ply"
"$SPLAT_TRANSFORM" -w "$tmp/reduced.ply" "$tmp/scene.sog"
rm -f "$tmp/reduced.ply"
made=$splats
fi
if [ -f "$tmp/clean.ply" ] && [ "$splats" = all ]; then
mkdir -p "$tmp/spirula" # for the PC: Spirula Studio opens the ply
mv "$tmp/clean.ply" "$tmp/spirula/scene.ply"
fi
rm -f "$tmp/clean.ply"
stem=$(printf '%s-%s' "$slug" "$key" | tr 'A-Z' 'a-z')
python3 - "$out/scene.xrSceneTransform.json" "$tmp/scene.xrSceneTransform.json" "$scale" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
data["worldScale"] = float(sys.argv[3]) # 1 = original size of the scene
json.dump(data, open(sys.argv[2], "w"), indent=2)
PY
if [ -n "$preview" ]; then
cp "$preview" "$tmp/$stem.gallery.png"
fi
uv run -q "$HERE/make_gallery.py" --label "$tmp/$stem.sceneinfo.png" "$plate"
python3 - "$tmp/version.json" "$key" "$made" "$splats" "$scale" "$plate" "${clean:-}" <<'PY'
import json, sys, time
path, key, made, target, scale, plate, clean = sys.argv[1:]
info = {"key": key, "splats": int(made), "target": target, "scale": float(scale), "label": plate,
"created": time.time()}
if clean:
info["clean"] = {k: float(v) for k, v in (kv.split("=") for kv in clean.split(","))}
json.dump(info, open(path, "w"), indent=1)
PY
rm -rf "$dir"
mv "$tmp" "$dir"
trap - EXIT
done
echo "@@ done local"
echo "Done: $out/versions"
exit
}
+57 -8
View File
@@ -5,7 +5,9 @@ Order: built-in defaults < ~/.config/splat-workshop/config.toml (or $SPLAT_WORKS
`python3 -m splat_workshop.config --shell` prints the values as shell assignments for scripts/.
"""
import difflib
import os
import re
import shlex
import shutil
import sys
@@ -25,15 +27,54 @@ DEFAULTS = {
"recordings": "~/SplatWorkshop/recordings",
"runs": "~/SplatWorkshop/runs",
"extra_recording_dirs": ["~/Downloads", "~/Videos"],
"video_player": "",
},
"frame": {"host": ""},
"server": {"port": 8771, "lan": False, "idle_minutes": 20, "language": "auto"},
}
def unknown_keys(data: dict) -> list[tuple[str, str]]:
"""Settings in the config file that Splat Workshop does not know (e.g. a typo: "splat-transform"),
as ("section.key", suggestion or "")."""
out = []
for section, values in data.items():
if section not in DEFAULTS:
close = difflib.get_close_matches(section, DEFAULTS, n=1, cutoff=0.6)
out.append((section, close[0] if close else ""))
continue
if not isinstance(values, dict):
continue
for key in values:
if key not in DEFAULTS[section]:
close = difflib.get_close_matches(key.replace("-", "_"), DEFAULTS[section], n=1, cutoff=0.6)
out.append((f"{section}.{key}", f"{section}.{close[0]}" if close else ""))
return out
# PyAV 19 removed av.open(metadata_errors=...), which the Arcturus pipeline (autumn 2026) uses
PYAV_FILES = ("av1_to_3dgs.py", "av1_colmap.py")
RX_AV_DEP = re.compile(r'^#\s*"av(?P<spec>[^"]*)"', re.M)
def pyav_unpinned(pipeline: Path) -> list[str]:
"""Pipeline scripts that use metadata_errors but allow PyAV 19 or newer."""
out = []
for name in PYAV_FILES:
try:
text = (pipeline / name).read_text(errors="replace")
except OSError:
continue
m = RX_AV_DEP.search(text)
if "metadata_errors" in text and m and "<19" not in m["spec"].replace(" ", ""):
out.append(name)
return out
class Config:
def __init__(self, data: dict, source: Path | None):
def __init__(self, data: dict, source: Path | None, unknown=()):
self.source = source
self.unknown = list(unknown)
base = source.parent if source else Path.home()
def path(v: str) -> Path:
@@ -48,6 +89,8 @@ class Config:
self.recordings = path(p["recordings"])
self.runs = path(p["runs"])
self.extra_recording_dirs = [path(d) for d in p.get("extra_recording_dirs", [])]
# Command for "Open in video player" (e.g. "mpv"); empty = system default via xdg-open
self.video_player = shlex.split(p.get("video_player", "")) or ["xdg-open"]
self.frame_host = os.environ.get("FRAME_HOST", f.get("host", "")).strip()
self.port = int(os.environ.get("SPLAT_PORT", s["port"]))
lan = os.environ.get("SPLAT_LAN")
@@ -60,12 +103,17 @@ class Config:
def problems(self) -> list[tuple[str, dict]]:
"""Missing prerequisites as (message key, params) for the setup hints in the UI."""
out = []
for key, suggestion in self.unknown:
out.append(("setup.unknown_key" if suggestion else "setup.unknown_key.plain",
{"key": key, "suggestion": suggestion, "file": str(self.source)}))
if not (self.pipeline / "av1_to_3dgs.py").is_file():
out.append(("setup.pipeline", {"path": str(self.pipeline)}))
elif bad := pyav_unpinned(self.pipeline):
out.append(("setup.pyav", {"files": ", ".join(bad), "path": str(self.pipeline)}))
if not os.access(self.spirula, os.X_OK):
out.append(("setup.spirula", {"path": str(self.spirula)}))
if not self.splat_transform:
out.append(("setup.splat_transform", {}))
out.append(("setup.splat_transform", {"file": str(self.source or CONFIG_FILE)}))
for tool in ("ffmpeg", "ffprobe", "uv"):
if not shutil.which(tool):
out.append(("setup.tool", {"tool": tool}))
@@ -73,7 +121,8 @@ class Config:
def slugify(name: str) -> str:
"""Folder/file name from a display name: ä→ae etc., accents dropped, only [A-Za-z0-9._-]."""
"""Folder/file name from a display name: ä→ae etc., accents dropped, only [A-Za-z0-9._-]; no dot or
dash at the ends ("Tour 12.10." → Tour-12.10: Windows file systems such as NTFS refuse a trailing dot)."""
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("Ä", "Ae"), ("Ö", "Oe"), ("Ü", "Ue"), ("ß", "ss")):
name = name.replace(a, b)
name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
@@ -85,7 +134,7 @@ def slugify(name: str) -> str:
elif not dash:
out.append("-")
dash = True
return "".join(out).strip("-")
return "".join(out).strip("-.")
def _merge(base: dict, over: dict) -> dict:
@@ -99,12 +148,12 @@ def _merge(base: dict, over: dict) -> dict:
def load() -> Config:
data, source = DEFAULTS, None
data, source, unknown = DEFAULTS, None, []
if CONFIG_FILE.is_file():
with open(CONFIG_FILE, "rb") as fh:
data = _merge(DEFAULTS, tomllib.load(fh))
source = CONFIG_FILE
return Config(data, source)
own = tomllib.load(fh)
data, source, unknown = _merge(DEFAULTS, own), CONFIG_FILE, unknown_keys(own)
return Config(data, source, unknown)
def shell(cfg: Config) -> str:
+40
View File
@@ -8,8 +8,28 @@ TEXTS = {
"notify.done.frame": "“{name}” is in the Gaussian XR gallery. Took {mins} min",
"notify.done.local": "“{name}” is finished (on this PC only). Took {mins} min",
"notify.done.title": "Scene finished",
"inhibit.job": "Converting “{name}”",
"inhibit.upload": "Copying “{name}” to the Frame",
"inhibit.download": "Downloading a recording from the Frame",
"inhibit.combine": "Putting “{name}” together",
"tour.full_name": "{name} (full)",
"inhibit.busy": "Busy (converting, putting together, copying)",
"notify.standby.title": "Everything done", "notify.standby": "The PC goes to standby in {s} seconds – to stop it, untick “Standby when done” in Splat Workshop.",
"notify.standby_failed.title": "No standby", "notify.standby_failed": "The PC could not go to standby: {why}",
"inhibit.versions": "Making versions of “{name}”", "ver.all": "all splats", "ver.size": "{n} M",
"ver.clean.ceiling": "ceiling", "ver.clean.floor": "floor", "ver.clean.haze": "haze", "ver.clean.outside": "edges",
"notify.failed.title": "Conversion failed",
"notify.failed": "“{name}” – details in Splat Workshop",
"notify.download.title": "Fetched from the Frame",
"notify.download": "{n} recording(s) are on this PC – you can switch the Frame off",
"notify.download_failed.title": "Fetching failed",
"notify.download_failed": "{n} recording(s) could not be fetched ({ok} did) – is the Frame switched on?",
"part.name": "{name} · part {i} of {n}",
"notify.parts_done.title": "All parts converted",
"notify.parts_done": "“{name}”: {n} parts are one room now – it is being put together",
"notify.parts_done_alone": "“{name}”: {n} parts are one room now – put it together in Splat Workshop",
"notify.parts_failed.title": "Conversion in parts stopped",
"notify.parts_failed": "“{name}”: part {i} of {n} failed – details in Splat Workshop",
"notify.upload.title": "Copied to the Frame",
"notify.upload": "“{name}” is in the Gaussian XR gallery",
"notify.upload_failed.title": "Copying failed",
@@ -52,8 +72,28 @@ TEXTS = {
"notify.done.frame": "„{name}“ liegt in der Gaussian-XR-Galerie. Dauer: {mins} min",
"notify.done.local": "„{name}“ ist fertig (nur auf dem PC). Dauer: {mins} min",
"notify.done.title": "Szene fertig",
"inhibit.job": "Wandelt „{name}“ um",
"inhibit.upload": "Kopiert „{name}“ auf die Frame",
"inhibit.download": "Lädt eine Aufnahme von der Frame",
"inhibit.combine": "Setzt „{name}“ zusammen",
"tour.full_name": "{name} (voll)",
"inhibit.busy": "Beschäftigt (Umwandeln, Zusammensetzen, Kopieren)",
"notify.standby.title": "Alles fertig", "notify.standby": "Der PC geht in {s} Sekunden in den Standby – zum Abbrechen in der Splat-Werkstatt den Haken bei „Danach in den Standby“ entfernen.",
"notify.standby_failed.title": "Kein Standby", "notify.standby_failed": "Der PC konnte nicht in den Standby: {why}",
"inhibit.versions": "Erstellt Fassungen von „{name}“", "ver.all": "alle Splats", "ver.size": "{n} Mio.",
"ver.clean.ceiling": "Decke", "ver.clean.floor": "Boden", "ver.clean.haze": "Dunst", "ver.clean.outside": "Rand",
"notify.failed.title": "Umwandlung fehlgeschlagen",
"notify.failed": "„{name}“ – Details in der Splat-Werkstatt",
"notify.download.title": "Von der Frame geholt",
"notify.download": "{n} Aufnahme(n) liegen auf dem PC – du kannst die Frame abschalten",
"notify.download_failed.title": "Holen fehlgeschlagen",
"notify.download_failed": "{n} Aufnahme(n) konnten nicht geholt werden ({ok} schon) – ist die Frame an?",
"part.name": "{name} · Teil {i} von {n}",
"notify.parts_done.title": "Alle Teile umgewandelt",
"notify.parts_done": "„{name}“: {n} Teile sind jetzt ein Raum – er wird zusammengesetzt",
"notify.parts_done_alone": "„{name}“: {n} Teile sind jetzt ein Raum – in der Splat-Werkstatt zusammensetzen",
"notify.parts_failed.title": "Umwandlung in Teilen angehalten",
"notify.parts_failed": "„{name}“: Teil {i} von {n} ist fehlgeschlagen – Details in der Splat-Werkstatt",
"notify.upload.title": "Auf die Frame übertragen",
"notify.upload": "„{name}“ liegt in der Gaussian-XR-Galerie",
"notify.upload_failed.title": "Übertragung fehlgeschlagen",
+1728 -100
View File
File diff suppressed because it is too large. Load diff
+345
View File
@@ -0,0 +1,345 @@
"""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) | room (a synthetic recording of
# room A from tests/synth.py that can really be put together: "part-1" in the recording's name walks round
# the left half, other parts the right half)
script(PIPE / "av1_to_3dgs.py", r'''
import base64, json, os, sys, time
from pathlib import Path
TESTS = %r''' % str(Path(__file__).parent) + r'''
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")
if mode == "room":
import re, shutil, tempfile
sys.path.insert(0, TESTS)
import synth
first = re.search(r"part-1-of", Path(sys.argv[1]).name) is not None
tmp = Path(tempfile.mkdtemp())
made = synth.make_room(tmp, "x", "A", loop=(1.1, 1.5, 0.6, 0.8) if first else (2.9, 1.5, 0.6, 0.8),
colour=(1, 0, 0) if first else (0, 1, 0))
shutil.copytree(made / "dataset", out / "dataset", dirs_exist_ok=True)
shutil.copy(made / "spirula" / "scene.ply", out / "spirula" / "scene.ply")
shutil.rmtree(tmp)
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")
''')
# systemctl: the tests must never suspend this PC – a note in the tool log; FAKE_SUSPEND_FAIL=1: refused
script(BIN / "systemctl", f'''#!{sys.executable}
import json, os, sys
with open("{TOOL_LOG}", "a") as log:
log.write(json.dumps(["systemctl", *sys.argv[1:]]) + "\\n")
if os.environ.get("FAKE_SUSPEND_FAIL"):
sys.exit("Operation inhibited by \\"Steam\\" (Steam is downloading)")
''')
# 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")
''')
# ffmpeg: cutting a part of a recording (-copyts) is only noted in the tool log and gives a small file;
# everything else goes to the real ffmpeg
script(BIN / "ffmpeg", f'''#!{sys.executable}
import json, os, sys
args = sys.argv[1:]
if "-copyts" in args:
with open("{TOOL_LOG}", "a") as log:
log.write(json.dumps(["ffmpeg", *args]) + "\\n")
open(args[-1], "wb").write(b"a part of a recording")
sys.exit(0)
real = {shutil.which("ffmpeg")!r}
if not real:
sys.exit("no ffmpeg")
os.execv(real, [real, *args])
''')
# 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)
if os.environ.get("FAKE_FRAME_SHORT") and cmd.startswith("cat "): # ends early, but without an error
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(0)
if os.environ.get("FAKE_FRAME_SLOW") and cmd.startswith("cat "): # a slow connection: 1 kB every 0.1 s
import time
out = subprocess.run(["bash", "-c", cmd], cwd="{FRAME_HOME}", env=env, capture_output=True).stdout
for i in range(0, len(out), 1000):
sys.stdout.buffer.write(out[i:i + 1000]); sys.stdout.buffer.flush(); time.sleep(0.1)
sys.exit(0)
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)
if os.environ.get("FAKE_SCP_SLOW"): # a slow connection: copying takes 3 s
import time
time.sleep(3)
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
for p in server.notes: # notifications still on their way belong to this test
p.wait(10)
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()
if server.download_busy():
server.cancel_download()
end = time.time() + 10
while server.download_busy() and time.time() < end:
time.sleep(0.05)
server.download, server.download_queue[:], server.download_batch = None, [], {}
for k in ("FAKE_PIPELINE", "FAKE_SUSPEND_FAIL", "FAKE_FRAME_OFF", "FAKE_FRAME_CUT", "FAKE_FRAME_SLOW", "FAKE_FRAME_SHORT", "FAKE_SCP_SLOW",
"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, server.FRAME_STEAM):
(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)
+196
View File
@@ -0,0 +1,196 @@
"""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, loop=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.
loop: (x, z, radius x, radius z) – a round of its own in the room instead of the usual one, looking at the
walls (outwards): several recordings of one room, e.g. one for each half.
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])
target = lambda c: centre
if loop:
lx, lz, rx, rz = loop
t = np.linspace(0, 2 * np.pi, 140)
pos = np.stack([lx + rx * np.cos(t), np.zeros_like(t), lz + rz * np.sin(t)], 1)
times = np.linspace(0, 40, len(pos))
target = lambda c: 2 * c - np.array([lx, 0.0, lz]) # outwards, at the walls
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(), target(c))
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
+103
View File
@@ -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)]
+236
View File
@@ -0,0 +1,236 @@
"""Cleaning up a scene (0.4, scripts/clean.py): ceiling, floor, haze, outside – and as part of its versions.
The synthetic room is built so every kind of splat can be told apart afterwards by its colour: a white
ceiling that the reconstruction left thin, a dark veil under it, a dark cupboard top by the wall, a bird
cage of long thin bars, fat transparent blobs (haze), a floor with a hole, and things beyond the walls.
The expectations come from how it is built, not from the code."""
import json
import subprocess
import sys
import numpy as np
import pytest
from conftest import ROOT, RUNS, server, wait_job
import synth
CLEAN = ROOT / "scripts" / "clean.py"
FLOOR, CEILING = 1.6, -0.9 # y points down: the room is 2.5 m high
W, D = 4.0, 5.0 # x and z extent of the room
WALL, FLOORC, CEILC = (0.6, 0.6, 0.6), (0.45, 0.3, 0.2), (0.85, 0.85, 0.82)
VEIL, CUPBOARD, CAGE, HAZE, OUT = (0.12, 0.12, 0.13), (0.2, 0.1, 0.05), (0.3, 0.3, 0.35), (0.5, 0.5, 0.9), (0.9, 0.1, 0.9)
LOGIT = lambda a: float(np.log(a / (1 - a)))
def splats(pts, colour, opacity=0.95, scale=(0.02, 0.02, 0.02)):
v = np.zeros(len(pts), dtype=np.dtype([(p, "<f4") for p in synth.PROPS]))
v["x"], v["y"], v["z"] = np.asarray(pts).T
for k in range(3):
v[f"f_dc_{k}"] = (colour[k] - 0.5) / 0.28209479177387814
v[f"scale_{k}"] = np.log(scale[k])
v["opacity"] = LOGIT(opacity)
v["rot_0"] = 1.0
return v
def grid(a0, a1, b0, b1, step):
a, b = np.meshgrid(np.arange(a0, a1 + 1e-9, step), np.arange(b0, b1 + 1e-9, step), indexing="ij")
return a.ravel(), b.ravel()
def room(path):
"""The living room as a reconstruction of a white ceiling tends to come out."""
rng = np.random.default_rng(1)
parts = []
ys = np.arange(CEILING, FLOOR + 1e-9, 0.04)
for x in (0.0, W): # walls
z, y = grid(0, D, CEILING, FLOOR, 0.04)
parts.append(splats(np.stack([np.full_like(z, x), y, z], 1), WALL))
for z in (0.0, D):
x, y = grid(0, W, CEILING, FLOOR, 0.04)
parts.append(splats(np.stack([x, y, np.full_like(x, z)], 1), WALL))
x, z = grid(0, W, 0, D, 0.03) # floor with a hole of 1 x 1 m
hole = (x > 1.5) & (x < 2.5) & (z > 2.0) & (z < 3.0)
parts.append(splats(np.stack([x[~hole], np.full((~hole).sum(), FLOOR), z[~hole]], 1), FLOORC))
x, z = grid(0, W, 0, D, 0.12) # thin ceiling: little, half transparent ...
parts.append(splats(np.stack([x, np.full_like(x, CEILING), z], 1), CEILC, opacity=0.4))
x, z = grid(0, W, 0, D, 0.03) # ... but solid where it meets the walls
edge = (x < 0.25) | (x > W - 0.25) | (z < 0.25) | (z > D - 0.25)
parts.append(splats(np.stack([x[edge], np.full(edge.sum(), CEILING), z[edge]], 1), CEILC))
n = 4000 # the veil: dark, thin, 10–30 cm under it
veil = np.stack([rng.uniform(0.8, W - 0.8, n), CEILING + rng.uniform(0.1, 0.3, n), rng.uniform(0.8, D - 0.8, n)], 1)
parts.append(splats(veil, VEIL, opacity=0.2, scale=(0.03, 0.03, 0.03)))
x, z = grid(0.05, 0.4, 1.0, 2.0, 0.03) # a dark cupboard top at the wall, 2.2 m high
parts.append(splats(np.stack([x, np.full_like(x, FLOOR - 2.2), z], 1), CUPBOARD))
bars = [] # a bird cage: long thin bars, faint
for bx in np.arange(1.8, 2.2, 0.02):
for by in np.arange(FLOOR - 2.0, FLOOR - 1.2, 0.08):
bars.append((bx, by, 2.5))
parts.append(splats(bars, CAGE, opacity=0.15, scale=(0.001, 0.06, 0.001)))
n = 300 # haze: fat, transparent blobs in the room
blobs = np.stack([rng.uniform(1, 3, n), rng.uniform(FLOOR - 1.6, FLOOR - 0.6, n), rng.uniform(1, 4, n)], 1)
parts.append(splats(blobs, HAZE, opacity=0.06, scale=(0.06, 0.06, 0.06)))
n = 500 # beyond the walls, seen through the window
out = np.stack([rng.uniform(-3, -1.5, n), rng.uniform(FLOOR - 2, FLOOR, n), rng.uniform(0, D, n)], 1)
parts.append(splats(out, OUT))
v = np.concatenate(parts)
header = "ply\nformat binary_little_endian 1.0\nelement vertex %d\n%send_header\n" % (
len(v), "".join(f"property float {p}\n" for p in synth.PROPS))
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "wb") as f:
f.write(header.encode())
v.tofile(f)
return v
def read(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 colour_of(v):
return 0.5 + 0.28209479177387814 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1)
def count(v, rgb):
return int(np.all(np.abs(colour_of(v) - rgb) < 0.01, axis=1).sum())
def clean(tmp_path, *args):
src, dst = tmp_path / "in.ply", tmp_path / "out.ply"
before = room(src)
r = subprocess.run([sys.executable, str(CLEAN), str(src), str(dst), *args], capture_output=True, text=True)
assert r.returncode == 0, r.stderr
return before, read(dst), r.stdout
def test_ceiling_veil_out_ceiling_closed(tmp_path):
before, after, out = clean(tmp_path, "--ceiling", "0.5")
assert "Ceiling 2.5" in out # found where walls and ceiling meet
assert count(after, VEIL) == 0 # the veil is gone
assert count(after, CUPBOARD) == count(before, CUPBOARD) # the dark cupboard top by the wall stays
assert count(after, CAGE) == count(before, CAGE) # and the cage, far below the ceiling
flat = after[np.isclose(after["y"], CEILING, atol=0.03) & (after["opacity"] > 3)]
# closed: a solid layer over the whole room, in the ceiling's own colour
assert len(flat) > (W / 0.02) * (D / 0.02) * 0.9
assert np.allclose(np.median(colour_of(flat), 0), CEILC, atol=0.05)
for k in ("x", "y", "z", "f_dc_0", "opacity", "scale_0"):
assert np.isfinite(after[k]).all()
def test_floor_holes_filled_in_the_floors_colour(tmp_path):
before, after, out = clean(tmp_path, "--floor", "0.5")
added = after[len(before):]
inside = (added["x"] > 1.6) & (added["x"] < 2.4) & (added["z"] > 2.1) & (added["z"] < 2.9)
assert inside.sum() > 0.8 * (0.8 / 0.02) ** 2 # the hole is closed ...
assert len(added) < 1.3 * (1 / 0.02) ** 2 # ... and little more than the hole
assert np.allclose(np.median(colour_of(added), 0), FLOORC, atol=0.05)
assert np.allclose(added["y"], FLOOR, atol=0.01)
def test_haze_goes_but_not_thin_bars(tmp_path):
before, after, _ = clean(tmp_path, "--haze", "1")
assert count(after, HAZE) == 0
assert count(after, CAGE) == count(before, CAGE) # long and thin is no haze
assert count(after, VEIL) == 0 # at full strength the veil is haze, too
assert count(after, WALL) == count(before, WALL) and count(after, CUPBOARD) == count(before, CUPBOARD)
def test_outside_goes(tmp_path):
before, after, _ = clean(tmp_path, "--outside", "1")
assert count(after, OUT) == 0 and count(after, WALL) == count(before, WALL)
def test_nothing_switched_on_changes_nothing(tmp_path):
before, after, _ = clean(tmp_path)
assert len(after) == len(before) and np.array_equal(after["x"], before["x"])
@pytest.mark.parametrize("args", [["--haze", "2"], ["--ceiling", "-0.1"]])
def test_strengths_from_0_to_1(tmp_path, args):
src = tmp_path / "in.ply"
room(src)
r = subprocess.run([sys.executable, str(CLEAN), str(src), str(tmp_path / "o.ply"), *args], capture_output=True, text=True)
assert r.returncode != 0 and "between 0 and 1" in r.stderr
# ---------------------------------------------------------------- as part of the versions
def scene(slug="Living"):
run = RUNS / slug
(run / "spirula").mkdir(parents=True, exist_ok=True)
room(run / "spirula" / "scene.ply")
(run / "scene.sog").write_bytes(b"sog")
(run / "scene.xrSceneTransform.json").write_text(json.dumps({"worldScale": 1.0}))
server.write_meta(slug, name="Living room")
return run
def test_a_cleaned_version(api):
run = scene()
total = len(read(run / "spirula" / "scene.ply"))
status, r = api.post("/api/versions", {"slug": "Living", "sizes": "all", "clean": "ceiling=0.5,haze=0.3"})
assert status == 200, r
js = wait_job()
assert js["result"] == "ok", js["tail"]
v = {x["key"]: x for x in server.versions_of("Living")}
assert list(v) == ["all-c50h30"]
x = v["all-c50h30"]
assert x["clean"] == {"ceiling": 0.5, "haze": 0.3} and x["label"] == "Living room · all splats · ceiling 50 % · haze 30 %"
assert x["purpose"] == "pc" and x["ply"] # Spirula Studio can open it
info = json.loads((run / "versions" / "all-c50h30" / "version.json").read_text())
assert info["splats"] != total # counted after cleaning
assert not (run / "versions" / "all-c50h30" / "clean.ply").exists()
assert len(read(run / "versions" / "all-c50h30" / "spirula" / "scene.ply")) == info["splats"]
assert len(read(run / "spirula" / "scene.ply")) == total # the scene itself is untouched
def test_a_cleaned_and_a_plain_version_side_by_side(api):
scene()
api.post("/api/versions", {"slug": "Living", "sizes": "all"})
wait_job()
api.post("/api/versions", {"slug": "Living", "sizes": "all", "clean": "floor=1"})
wait_job()
assert sorted(x["key"] for x in server.versions_of("Living")) == ["all", "all-f100"]
@pytest.mark.parametrize("clean,code", [("roof=0.5", "err.bad_value"), ("haze=2", "err.range"), ("haze=x", "err.bad_value"),
("haze=nan", "err.bad_value"), ({"haze": 1}, "err.bad_request")])
def test_odd_clean_ups_are_refused(api, clean, code):
scene()
status, r = api.post("/api/versions", {"slug": "Living", "sizes": "all", "clean": clean})
assert status == 400 and r["error"]["code"] == code
def test_cleaned_and_plain_in_one_go(api):
"""For the Frame, with and without cleaning up, to compare: two versions, named by their strengths."""
run = scene()
status, r = api.post("/api/versions", {"slug": "Living", "sizes": "20000", "clean": "haze=0.4", "also_plain": True})
assert status == 200, r
assert wait_job()["result"] == "ok"
v = {x["key"]: x for x in server.versions_of("Living")}
assert set(v) == {"0.02m", "0.02m-h40"}
assert v["0.02m-h40"]["label"] == "Living room · 0.02 M · haze 40 %" and v["0.02m"]["label"] == "Living room · 0.02 M"
assert all(x["purpose"] == "frame" and not x["ply"] for x in v.values()) # Frame versions keep no ply
assert not (run / "versions" / "0.02m-h40" / "spirula").exists()
def test_a_version_opens_in_spirula(api, monkeypatch):
run = scene()
api.post("/api/versions", {"slug": "Living", "sizes": "all", "clean": "ceiling=0.5"})
wait_job()
api.post("/api/versions", {"slug": "Living", "sizes": "20000"})
wait_job()
opened = []
monkeypatch.setattr(server.subprocess, "Popen", lambda cmd, **kw: opened.append(cmd))
assert api.post("/api/open", {"slug": "Living", "version": "all-c50", "what": "spirula"})[0] == 200
assert opened[-1][1] == str(run / "versions" / "all-c50" / "spirula" / "scene.ply")
status, r = api.post("/api/open", {"slug": "Living", "version": "0.02m", "what": "spirula"}) # no ply
assert status == 400 and r["error"]["code"] == "err.no_version"
status, r = api.post("/api/open", {"slug": "Living", "version": "../x", "what": "spirula"})
assert status == 400 and len(opened) == 1
+190
View File
@@ -0,0 +1,190 @@
"""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", colour="keep"):
"""Colour matching is off by default here: the rooms' and markers' own colours show where a splat came from."""
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, "--colour", colour], 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 (less
since 0.3: a cube only goes to a recording that really has something there)."""
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
nearest, _ = from_b_in_room_a("nearest")
assert nearest > 2 * 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()
+47
View File
@@ -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
+132
View File
@@ -0,0 +1,132 @@
"""The "On the Frame" tab: everything in Gaussian XR's gallery, what it is, and taking it off the Frame."""
import json
import time
from conftest import RUNS, server
import synth
def upload(api, slug, version=""):
body = {"slug": slug, **({"version": version} if version else {})}
assert api.post("/api/upload", body)[0] == 200
key = f"{slug}#{version}" if version else slug
end = time.time() + 60
while (u := server.upload_status(key)) and u["running"] and time.time() < end:
time.sleep(0.2)
assert server.upload_status(key)["ok"]
def example(frame, name="04_BurtLakeHouse"):
d = frame / server.FRAME_STEAM / name # Gaussian XR's own gallery folder
d.mkdir()
(d / "trained-572k.htgs.gallery.png").write_bytes(b"example preview")
(d / "trained-572k.htgs.ply").write_bytes(b"x" * 5000)
return d
def test_the_gallery_on_the_frame(api, frame):
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
upload(api, "Kitchen")
assert server.start_versions({"slug": "Kitchen", "sizes": "10000"}) is None
from conftest import wait_job
wait_job()
upload(api, "Kitchen", "0.01m")
example(frame)
(frame / server.FRAME_STEAM / "common_audio").mkdir() # not a scene: no preview image
status, r = api.get("/api/frame/gallery?refresh=1")
assert status == 200 and r["online"]
by = {x["folder"]: x for x in r["scenes"]}
assert set(by) == {"04_BurtLakeHouse", "20_Kitchen", "21_Kitchen-0.01m"}
assert (by["20_Kitchen"]["kind"], by["20_Kitchen"]["name"], by["20_Kitchen"]["slug"]) == ("scene", "Kitchen", "Kitchen")
assert (by["21_Kitchen-0.01m"]["kind"], by["21_Kitchen-0.01m"]["version"]) == ("version", "0.01m")
assert (by["04_BurtLakeHouse"]["kind"], by["04_BurtLakeHouse"]["name"]) == ("other", "BurtLakeHouse")
assert by["04_BurtLakeHouse"]["size"] >= 5000
assert (by["04_BurtLakeHouse"]["place"], by["20_Kitchen"]["place"]) == ("steam", "own")
status, png = api.get("/api/frame/preview?folder=04_BurtLakeHouse")
assert status == 200 and png == b"example preview"
assert api.get("/api/frame/preview?folder=../../.ssh")[0] == 404
def test_taking_a_scene_off_the_frame_and_copying_it_again(api, frame):
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
upload(api, "Kitchen")
assert api.post("/api/frame/remove", {"folder": "20_Kitchen"})[0] == 200
assert not (frame / server.FRAME_SCENES / "20_Kitchen").exists()
assert (RUNS / "Kitchen" / "scene.sog").exists() # the PC is not touched
assert json.loads((RUNS / "Kitchen.ui.json").read_text()).get("frame_folder") is None
upload(api, "Kitchen") # copying again works
assert (frame / server.FRAME_SCENES / "20_Kitchen" / "kitchen.sog").exists()
def test_an_example_scene_can_be_deleted_too(api, frame):
d = example(frame)
assert api.post("/api/frame/remove", {"folder": "04_BurtLakeHouse"})[0] == 200
assert not d.exists()
def test_only_scenes_of_the_gallery_can_be_removed(api, frame):
(frame / server.FRAME_STEAM / "common_audio").mkdir()
(frame / "Videos" / "keep.txt").write_text("x")
for folder in ("common_audio", "..", "../..", "../../Videos", "nope"):
status, r = api.post("/api/frame/remove", {"folder": folder})
assert status == 400, folder
assert (frame / server.FRAME_STEAM / "common_audio").exists() and (frame / "Videos" / "keep.txt").exists()
def old_copy(frame, folder, stem, content=b"old"):
"""A scene copied by Splat Workshop 0.2.3 or older: in Gaussian XR's own gallery folder."""
d = frame / server.FRAME_STEAM / folder
d.mkdir()
for ext in ("sog", "xrSceneTransform.json", "sceneinfo.png"):
(d / f"{stem}.{ext}").write_bytes(content)
(d / f"{stem}.gallery.png").write_bytes(b"preview")
return d
def test_scenes_go_to_the_personal_folder_and_numbers_count_both(api, frame):
old_copy(frame, "20_Other", "other") # e.g. a scene of someone else
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
upload(api, "Kitchen")
assert sorted(p.name for p in (frame / server.FRAME_SCENES).iterdir()) == ["21_Kitchen"]
assert (frame / server.FRAME_STEAM / "20_Other").exists()
def test_copying_again_moves_an_old_copy_over(api, frame):
old = old_copy(frame, "22_Kitchen", "kitchen")
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
upload(api, "Kitchen")
assert not old.exists()
new = frame / server.FRAME_SCENES / "22_Kitchen"
assert (new / "kitchen.sog").read_bytes() != b"old"
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["frame_folder"] == "22_Kitchen"
def test_moving_the_old_copies_to_the_personal_folder(api, frame):
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
synth.make_room(RUNS, "Hallway", "B", name="Hallway")
old_copy(frame, "20_Kitchen", "kitchen")
old_copy(frame, "21_Hallway", "hallway")
example(frame)
newer = frame / server.FRAME_SCENES / "21_Hallway" # already copied again: the newer one stays
newer.mkdir(parents=True)
(newer / "hallway.sog").write_bytes(b"new")
(newer / "hallway.gallery.png").write_bytes(b"preview")
assert api.post("/api/frame/move", {})[0] == 200
assert sorted(p.name for p in (frame / server.FRAME_STEAM).iterdir()) == ["04_BurtLakeHouse"] # the example stays
assert sorted(p.name for p in (frame / server.FRAME_SCENES).iterdir()) == ["20_Kitchen", "21_Hallway"]
assert (frame / server.FRAME_SCENES / "20_Kitchen" / "kitchen.sog").read_bytes() == b"old"
assert (newer / "hallway.sog").read_bytes() == b"new"
kinds = {(x["folder"], x["place"]) for x in api.get("/api/frame/gallery?refresh=1")[1]["scenes"]}
assert kinds == {("04_BurtLakeHouse", "steam"), ("20_Kitchen", "own"), ("21_Hallway", "own")}
assert {s["slug"]: s["on_frame"] for s in server.scenes()} == {"Kitchen": True, "Hallway": True}
def test_the_same_folder_in_both_places(api, frame):
old_copy(frame, "20_Kitchen", "kitchen")
d = frame / server.FRAME_SCENES / "20_Kitchen"
d.mkdir()
(d / "kitchen.gallery.png").write_bytes(b"own preview")
assert api.get("/api/frame/preview?folder=20_Kitchen&place=own") == (200, b"own preview")
assert api.post("/api/frame/remove", {"folder": "20_Kitchen", "place": "steam"})[0] == 200
assert d.exists() and not (frame / server.FRAME_STEAM / "20_Kitchen").exists()
assert api.post("/api/frame/remove", {"folder": "20_Kitchen", "place": "nowhere"})[0] == 400
+96
View File
@@ -0,0 +1,96 @@
"""Groups of scenes: one group per scene, suggestions, renaming, cleaning up together."""
import json
import pytest
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()}
@pytest.mark.parametrize("name,slug", [("Rundgang 12.10.", "Rundgang-12.10"), ("Küche", "Kueche"),
("...", ""), (".hidden", "hidden"), ("Flur 2", "Flur-2")])
def test_folder_names_suit_every_file_system(name, slug):
"""No dot at the end: NTFS (e.g. runs on a Windows disk) refuses it, and the copy loses the tour."""
from splat_workshop import config
assert config.slugify(name) == slug
def test_a_tour_named_with_a_date_gets_a_folder_ntfs_accepts(api):
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
synth.make_room(RUNS, "Hall", "B", name="Hall")
status, r = api.post("/api/group", {"name": "Tour 12.10.", "members": "Kitchen,Hall"})
assert status == 200 and r["slug"] == "Tour-12.10"
+253
View File
@@ -0,0 +1,253 @@
"""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
# the new actions of 0.2.3 leave it alone, too
v = RUNS / "Kitchen" / "versions" / "0.01m"
v.mkdir(parents=True)
(v / "scene.sog").write_bytes(b"x")
(v / "version.json").write_text(json.dumps({"key": "0.01m", "splats": 10000, "scale": 1, "label": "Kitchen · 0.01 M"}))
for path, body, code in (("/api/scale", {"slug": "Kitchen", "scale": "87"}, "err.scene_busy"),
("/api/version/delete", {"slug": "Kitchen", "version": "0.01m"}, "err.scene_busy"),
("/api/versions", {"slug": "Kitchen", "sizes": "10000"}, "err.job_busy")):
status, r = api.post(path, body)
assert status == 400 and r["error"]["code"] == code, path
assert v.is_dir() and not (server.read_meta("Kitchen").get("scale") or 1) - 1
# and its recording stays
rec = server.load_state()["job"]["recording"]
status, r = api.post("/api/recording/delete", {"name": rec, "where": "pc"})
assert status == 400 and r["error"]["code"] == "err.recording_busy" and (server.REC / rec).exists()
# 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")
+161
View File
@@ -0,0 +1,161 @@
"""A long recording in parts (0.3): each part gets up to 500 image pairs, the parts become one room.
The pipeline matches its images to the poses by the time stamps inside the recording, so a part has to be
cut with the original time (ffmpeg -copyts) – checked here on the command splat.sh runs. The fake pipeline
(FAKE_PIPELINE=room) gives every part a synthetic round through one half of room A, so putting the parts
together really runs."""
import json
import os
import subprocess
import pytest
from conftest import REC, ROOT, RUNS, TRASH, recording, server, tools_called, wait_for_tool, wait_job
NAME = "2026-10-14_16-57-54_stereo.mp4" # the fake ffprobe makes it 60 s long
def cuts():
return [c for c in tools_called() if c[0] == "ffmpeg"]
def run_chain(timeout=120):
"""Let the chain run on as the server's watcher does: a tick after every job – and one more, which
must not do anything again."""
was = True
for _ in range(10):
js = wait_job(timeout)
running = server.watch_once(was)
server.watch_once(running)
nxt = server.job_status(tail=False)
if not running and nxt["out"] == js["out"]:
return js
assert running and nxt["running"], "the watcher reports the next job as running"
was = running
raise AssertionError("the chain did not end")
def notes():
for p in server.notes: # notifications are sent in the background
p.wait(10)
return [c for c in tools_called() if c[0] == "notify-send"]
def test_parts_are_equally_long_and_overlap_a_little():
assert server.part_ranges(240, 3) == [(0.0, 82.0), (78.0, 162.0), (158.0, 240)]
ranges = server.part_ranges(100, 2)
assert ranges[0][0] == 0 and ranges[-1][1] == 100 and ranges[0][1] - ranges[1][0] == server.PART_OVERLAP
def test_a_recording_in_parts_becomes_one_room(api):
os.environ["FAKE_PIPELINE"] = "room"
rec = recording(NAME)
status, r = api.post("/api/parts", {"path": str(rec), "name": "Living room", "quality": "high", "parts": 2,
"upload": False})
assert status == 200, r
job = server.job_status(tail=False)
assert (job["chain"]["i"], job["chain"]["n"], job["part"]) == (0, 2, "0.00-32.00")
js = run_chain()
assert js["kind"] == "combine" and js["result"] == "ok", js.get("tail")
# cut losslessly, with the original time, from the original – which stays
assert [c[1:] for c in cuts()] == [
["-v", "error", "-y", "-copyts", "-ss", "0.00", "-i", str(rec), "-to", "32.00", "-map", "0", "-c", "copy",
"-copy_unknown", str(RUNS / ".parts" / "Living-room-part-1-of-2.mp4.tmp.mp4")],
["-v", "error", "-y", "-copyts", "-ss", "28.00", "-i", str(rec), "-to", "60.00", "-map", "0", "-c", "copy",
"-copy_unknown", str(RUNS / ".parts" / "Living-room-part-2-of-2.mp4.tmp.mp4")]]
assert rec.read_bytes() == b"not really a video" and not (RUNS / ".parts").exists()
# the parts: the original is their recording, each knows its stretch
parts = {s: server.read_meta(s) for s in ("Living-room-part-1-of-2", "Living-room-part-2-of-2")}
assert {s: (m["recording"], m["part"]) for s, m in parts.items()} == {
"Living-room-part-1-of-2": (NAME, "0.00-32.00"), "Living-room-part-2-of-2": (NAME, "28.00-60.00")}
room = {g["slug"]: g for g in server.groups()["groups"]}["Living-room"]
assert room["room"] and room["members"] == list(parts) and room["result"] and not room["result"]["stale"]
info = json.loads((RUNS / "Living-room" / "combine.json").read_text())
assert {x["room"] for x in info["rooms"].values()} == {"Living room"}
assert [n[-1] for n in notes() if "part" in n[-1]] == [
"“Living room”: 2 parts are one room now – it is being put together"]
def test_a_failed_part_stops_the_chain():
os.environ["FAKE_PIPELINE"] = "fail"
rec = recording(NAME)
assert server.start_parts({"path": str(rec), "name": "Living room", "quality": "high", "parts": 3}) is None
js = run_chain()
assert js["result"] == "failed" and js["chain"]["i"] == 0
assert len(cuts()) == 1 and "Living-room" not in {g["slug"] for g in server.groups()["groups"]}
assert any("part 1 of 3 failed" in n[-1] for n in wait_for_tool("notify-send"))
def test_a_cancelled_part_stops_the_chain_quietly():
os.environ["FAKE_PIPELINE"] = "slow"
rec = recording(NAME)
assert server.start_parts({"path": str(rec), "name": "Living room", "quality": "high", "parts": 2}) is None
import time
time.sleep(1)
server.cancel_job()
js = run_chain(timeout=30)
assert js["result"] == "cancelled" and len(cuts()) == 1
assert not [n for n in notes() if "part" in n[-1]]
@pytest.mark.parametrize("body,code", [
({"parts": 1}, "err.range"), ({"parts": 11}, "err.range"), ({"parts": "x"}, "err.bad_value"),
({"parts": 4}, "err.parts_too_short"), # 15 s each
({"parts": 2, "path": "/etc/passwd"}, "err.unknown_recording"),
({"parts": 2, "name": "Taken"}, "err.scene_name_taken"),
])
def test_parts_that_cannot_work_are_refused(api, body, code):
rec = recording(NAME)
(RUNS / "Taken").mkdir()
full = {"path": str(rec), "name": "Living room", "quality": "high", **body}
status, r = api.post("/api/parts", full)
assert status == 400 and r["error"]["code"] == code
assert server.load_state().get("job") is None
def test_resuming_a_part_cuts_the_same_stretch():
os.environ["FAKE_PIPELINE"] = "fail"
rec = recording(NAME)
server.start_parts({"path": str(rec), "name": "Living room", "quality": "high", "parts": 2})
run_chain()
os.environ["FAKE_PIPELINE"] = "ok"
assert server.resume_run("Living-room-part-1-of-2", fresh=True) is None
js = wait_job()
assert js["part"] == "0.00-32.00" and js["result"] == "ok", js.get("tail")
def test_the_original_of_parts_is_only_deleted_when_asked_twice(api):
os.environ["FAKE_PIPELINE"] = "room"
rec = recording(NAME)
api.post("/api/parts", {"path": str(rec), "name": "Living room", "quality": "high", "parts": 2, "upload": False})
run_chain()
status, r = api.post("/api/recording/delete", {"name": NAME, "where": "pc"})
assert status == 400 and r["error"]["code"] == "err.recording_has_parts" and r["error"]["params"]["n"] == 2
assert rec.exists()
assert api.post("/api/recording/delete", {"name": NAME, "where": "pc", "force": True})[0] == 200
assert not rec.exists() and (TRASH / NAME).exists()
assert (RUNS / "Living-room-part-1-of-2" / "scene.sog").exists() # the scenes stay
def test_splat_sh_refuses_odd_stretches():
rec = recording(NAME)
for part in ("abc", "30-10", "5", "-3-4", "1-2;rm"):
r = subprocess.run([str(ROOT / "scripts" / "splat.sh"), str(rec), "Odd"], capture_output=True, text=True,
env={**os.environ, "PART": part, "NO_UPLOAD": "1"})
assert r.returncode == 1 and "PART must be" in r.stderr, part
assert not cuts() and not (RUNS / "Odd").exists()
assert list(REC.iterdir()) == [rec]
def test_deleting_a_part_that_did_not_finish_removes_its_cut():
"""A failed or cancelled part keeps its piece of video (for resuming); deleting the run deletes it too
– it is as big as that stretch of the recording, hidden and in no list."""
os.environ["FAKE_PIPELINE"] = "fail"
rec = recording(NAME)
server.start_parts({"path": str(rec), "name": "Living room", "quality": "high", "parts": 2})
run_chain()
cut = RUNS / ".parts" / "Living-room-part-1-of-2.mp4"
assert cut.exists()
assert server.delete_scene("Living-room-part-1-of-2") is None
assert not cut.exists() and not (RUNS / ".parts").exists() and not (TRASH / cut.name).exists()
assert rec.exists()
+321
View File
@@ -0,0 +1,321 @@
"""Recordings on the PC and on the Frame: renaming, the Frame's list, download, copying scenes over."""
import json
import os
import time
import pytest
from conftest import REC, RUNS, TRASH, recording, server, tools_called, 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 not (frame / "Documents" / "GaussianSplats").exists() # no second copy on the Frame
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"]
def wait_fetched(timeout=30):
end = time.time() + timeout
while server.download_busy() and time.time() < end:
time.sleep(0.05)
assert not server.download_busy()
return server.download_status()
def fetch_notes(wait=0):
"""Notifications about fetching (others of an earlier test may still arrive in the background)."""
end = time.time() + wait
while True:
notes = [c for c in tools_called() if c[0] == "notify-send" and "recording(s)" in c[-1]]
if notes or time.time() >= end:
return notes
time.sleep(0.05)
NAMES = [f"2026-10-14_1{i}-00-00_stereo.mp4" for i in range(4)]
def test_fetching_several_recordings_one_after_the_other(api, frame):
for i, n in enumerate(NAMES):
put_on_frame(frame, n, bytes([65 + i]) * 3000)
(REC / NAMES[0]).write_bytes(b"already here") # skipped, never overwritten
server.frame_listing(force=True)
os.environ["FAKE_FRAME_SLOW"] = "1"
assert api.post("/api/download", {"names": "\n".join(NAMES[:3])})[0] == 200
st = api.get("/api/status")[1]["download"]
assert st["busy"] and st["batch"]["files"] == 2
assert [st["name"], *st["queue"]] == NAMES[1:3] or st["queue"] == NAMES[1:3]
assert api.post("/api/download", {"names": f"{NAMES[3]}\n{NAMES[2]}"})[0] == 200 # added while running
assert api.post("/api/download", {"names": NAMES[2]})[1]["error"]["code"] == "err.download_busy"
st = wait_fetched()
assert st["batch"]["files"] == st["batch"]["done_files"] == 3 and st["batch"]["failed"] == []
assert (REC / NAMES[0]).read_bytes() == b"already here"
for i, n in enumerate(NAMES[1:], 1):
assert (REC / n).read_bytes() == bytes([65 + i]) * 3000
assert not list(REC.glob("*.part"))
note = fetch_notes(wait=5)
assert len(note) == 1 and "3 recording(s)" in note[0][-1] # one notification at the end
def test_a_failed_one_does_not_stop_the_others(api, frame):
for n in NAMES[:2]:
put_on_frame(frame, n)
server.frame_listing(force=True)
(frame / VIDEOS / NAMES[0]).unlink() # gone from the Frame in the meantime
assert api.post("/api/download", {"names": "\n".join(NAMES[:2])})[0] == 200
st = wait_fetched()
assert st["batch"]["failed"] == [NAMES[0]] and (REC / NAMES[1]).exists() and not (REC / NAMES[0]).exists()
assert not list(REC.glob("*.part"))
assert [n[-1] for n in fetch_notes(wait=5)] == ["1 recording(s) could not be fetched (1 did) – is the Frame switched on?"]
def test_cancelling_empties_the_queue(api, frame):
for n in NAMES[:3]:
put_on_frame(frame, n, b"x" * 20000)
server.frame_listing(force=True)
os.environ["FAKE_FRAME_SLOW"] = "1"
assert api.post("/api/download", {"names": "\n".join(NAMES[:3])})[0] == 200
time.sleep(0.3)
assert api.post("/api/download/cancel", {})[0] == 200
st = wait_fetched()
assert st["queue"] == [] and st["batch"]["cancelled"]
assert not list(REC.iterdir()) # no file and no half of one
assert not fetch_notes(wait=0.5)
assert api.post("/api/download/cancel", {})[1]["error"]["code"] == "err.no_download"
def test_fetching_only_what_is_on_the_frame(api, frame):
put_on_frame(frame, NAMES[0])
server.frame_listing(force=True)
for body in ({"names": "../../etc/passwd"}, {"names": "x\n.."}, {"names": "\n"}):
status, r = api.post("/api/download", body)
assert status == 400 and r["error"]["code"] == "err.not_on_frame", body
assert api.post("/api/download", {"names": [NAMES[0]]})[1]["error"]["code"] == "err.bad_request"
def test_deleting_a_recording_on_this_pc_moves_it_to_the_trash(api):
rec = recording()
assert api.post("/api/rename", {"name": rec.name, "new": "Kitchen"})[0] == 200
assert api.post("/api/recording/delete", {"name": "Kitchen.mp4", "where": "pc"})[0] == 200
assert not (REC / "Kitchen.mp4").exists() and (TRASH / "Kitchen.mp4").exists()
assert "Kitchen.mp4" not in json.loads((server.RECMETA).read_text()) # its old name is forgotten
assert api.get("/api/recordings")[1]["local"] == []
@pytest.mark.parametrize("name", ["", "..", "../state/state.json", "nope.mp4"])
def test_deleting_only_recordings_of_the_recordings_folder(api, name):
recording()
status, r = api.post("/api/recording/delete", {"name": name, "where": "pc"})
assert status == 400 and r["error"]["code"] == "err.not_local"
assert len(list(REC.iterdir())) == 1 and not list(TRASH.iterdir())
assert api.post("/api/recording/delete", {"name": "x", "where": "elsewhere"})[0] == 400
def test_a_recording_an_unfinished_scene_needs_stays(api):
rec = recording()
synth.make_room(RUNS, "Kitchen", "A", recording=rec.name)
(RUNS / "Kitchen" / "scene.sog").unlink() # not finished yet
status, r = api.post("/api/recording/delete", {"name": rec.name, "where": "pc"})
assert status == 400 and r["error"]["code"] == "err.recording_needed"
assert rec.exists()
def test_deleting_a_recording_on_the_frame(api, frame):
name, _ = put_on_frame(frame)
other, _ = put_on_frame(frame, NAMES[1])
(REC / name).write_bytes(b"the copy on this PC")
assert api.post("/api/recording/delete", {"name": name, "where": "frame"})[0] == 200
assert not (frame / VIDEOS / name).exists() and (frame / VIDEOS / other).exists()
assert (REC / name).read_bytes() == b"the copy on this PC" # the PC is not touched
assert [f["name"] for f in api.get("/api/recordings")[1]["frame"]] == [other]
for bad in (name, "../" + other, "thumbnails", ""):
status, r = api.post("/api/recording/delete", {"name": bad, "where": "frame"})
assert status == 400 and r["error"]["code"] == "err.not_on_frame", bad
assert (frame / VIDEOS / other).exists()
def test_a_recording_being_fetched_is_not_deleted(api, frame):
for n in NAMES[:2]:
put_on_frame(frame, n, b"x" * 20000)
server.frame_listing(force=True)
os.environ["FAKE_FRAME_SLOW"] = "1"
assert api.post("/api/download", {"names": "\n".join(NAMES[:2])})[0] == 200
for n in NAMES[:2]: # fetching right now, and waiting
status, r = api.post("/api/recording/delete", {"name": n, "where": "frame"})
assert status == 400 and r["error"]["code"] == "err.recording_fetching"
assert all((frame / VIDEOS / n).exists() for n in NAMES[:2])
def test_deleting_on_a_frame_that_is_off(api, frame):
name, _ = put_on_frame(frame)
os.environ["FAKE_FRAME_OFF"] = "1"
status, r = api.post("/api/recording/delete", {"name": name, "where": "frame"})
assert status == 400 and r["error"]["code"] == "err.frame_offline"
def test_a_recording_being_checked_is_not_deleted(api, frame):
rec = recording()
put_on_frame(frame, rec.name)
server.checks[rec.name] = {"state": "running"}
for where in ("pc", "frame"):
status, r = api.post("/api/recording/delete", {"name": rec.name, "where": where})
assert status == 400 and r["error"]["code"] == "err.recording_busy", where
assert rec.exists() and (frame / VIDEOS / rec.name).exists()
def test_a_download_that_ends_too_early_leaves_nothing_behind(api, frame):
name, _ = put_on_frame(frame)
server.frame_listing(force=True)
os.environ["FAKE_FRAME_SHORT"] = "1" # half the file, but no error from ssh
assert api.post("/api/download", {"name": name})[0] == 200
st = wait_fetched()
assert st["error"] and st["batch"]["failed"] == [name]
assert not (REC / name).exists() and not (REC / (name + ".part")).exists()
+324
View File
@@ -0,0 +1,324 @@
"""Rooms made of several recordings (0.3): putting them together with the rooms of a home.
Several recordings of one room share it: every part comes from the recording that saw it best, and the
recordings of one room are never mistaken for walking in from somewhere else. The expectations come from
how the synthetic recordings were built (tests/synth.py): each has its own colour, so where a splat came
from can be seen in the result – with colour matching off (COLOUR=keep), which would otherwise bring them
closer together."""
import json
import subprocess
import sys
import numpy as np
from conftest import BASE, ROOT, RUNS
import synth
COMBINE = ROOT / "scripts" / "combine.py"
RED, GREEN, HALL = (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.3, 0.3, 0.8)
def combine(runs, rooms=None, name="out", colour="keep"):
out = BASE / name
cmd = [sys.executable, str(COMBINE), str(out), *[f"{p}={p.name}" for p in runs]]
if rooms is not None:
cmd += ["--rooms", json.dumps(rooms)]
cmd += ["--colour", colour]
r = subprocess.run(cmd, capture_output=True, text=True)
return out, r
def splats(out):
with open(out / "spirula" / "scene.ply", "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])
v = np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
colour = 0.5 + 0.28209479177387814 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1)
return v, colour
def coloured(colour, rgb):
return np.all(np.abs(colour - np.array(rgb)) < 0.02, axis=1)
def test_a_room_from_two_recordings_takes_each_part_from_the_one_that_saw_it():
left = synth.make_room(RUNS, "Left", "A", loop=(1.1, 1.5, 0.6, 0.8), colour=RED)
right = synth.make_room(RUNS, "Right", "A", loop=(2.9, 1.5, 0.6, 0.8), colour=GREEN)
out, r = combine([left, right], rooms=["Kitchen", "Kitchen"])
assert r.returncode == 0, r.stderr
v, colour = splats(out)
red, green = coloured(colour, RED), coloured(colour, GREEN)
left_wall, right_wall = v["x"] < 0.3, (v["x"] > 3.7) & (v["x"] < 4.3)
assert red[left_wall].mean() > 0.9 and green[right_wall].mean() > 0.9
# each place once: both recordings saw the whole room, the result holds it once
made = json.loads((left / "synth.json").read_text())
assert len(v) <= made["own"] + made["seen_through_door"]
info = json.loads((out / "combine.json").read_text())
assert {l: x["room"] for l, x in info["rooms"].items()} == {"Left": "Kitchen", "Right": "Kitchen"}
assert all(x["walk_in_out_cameras"] == 0 for x in info["rooms"].values())
assert info["seams"]["Left|Right"]["cm"] < 1.0 # the two recordings match
def thin_out(run, keep, gain=1.0):
"""Change a synthetic recording's splats: keep only some (keep(v) -> mask), colours times `gain`."""
path = run / "spirula" / "scene.ply"
raw = path.read_bytes()
end = raw.index(b"end_header\n") + len(b"end_header\n")
props = [l.split()[-1].decode() for l in raw[:end].splitlines() if l.startswith(b"property")]
v = np.frombuffer(raw[end:], dtype=np.dtype([(p, "<f4") for p in props])).copy()
v = v[keep(v)]
for k in range(3):
v[f"f_dc_{k}"] = (gain * (0.5 + 0.28209479177387814 * v[f"f_dc_{k}"]) - 0.5) / 0.28209479177387814
header = raw[:end].replace(b"element vertex %d" % int(raw[:end].split(b"element vertex ")[1].split()[0]),
b"element vertex %d" % len(v))
path.write_bytes(header + v.tobytes())
def test_a_place_goes_to_a_recording_that_really_has_something_there():
"""The right recording looked at the right wall, but its training left nothing there (behind the sofa,
too dark, ...): the wall comes from the left recording instead of being a dark hole."""
left = synth.make_room(RUNS, "Left", "A", loop=(1.1, 1.5, 0.6, 0.8), colour=RED)
right = synth.make_room(RUNS, "Right", "A", loop=(2.9, 1.5, 0.6, 0.8), colour=GREEN)
thin_out(right, lambda v: v["x"] < 3.0)
out, r = combine([left, right], rooms=["Kitchen", "Kitchen"])
assert r.returncode == 0, r.stderr
v, colour = splats(out)
right_wall = (v["x"] > 3.7) & (v["x"] < 4.3)
assert right_wall.sum() > 1000 and coloured(colour, RED)[right_wall].mean() > 0.9
assert coloured(colour, GREEN)[v["x"] < 0.3].mean() < 0.1 # the left wall still from the left
def test_recordings_of_one_room_are_brought_to_one_brightness():
grey = (0.6, 0.6, 0.6)
left = synth.make_room(RUNS, "Left", "A", loop=(1.1, 1.5, 0.6, 0.8), colour=grey)
right = synth.make_room(RUNS, "Right", "A", loop=(2.9, 1.5, 0.6, 0.8), colour=grey)
for run in (left, right): # some view-dependent colour, too
thin_out(run, lambda v: v["x"] > -1)
raw = (run / "spirula" / "scene.ply").read_bytes()
end = raw.index(b"end_header\n") + len(b"end_header\n")
props = [l.split()[-1].decode() for l in raw[:end].splitlines() if l.startswith(b"property")]
v = np.frombuffer(raw[end:], dtype=np.dtype([(p, "<f4") for p in props])).copy()
v["f_rest_0"] = 0.1 # red, first band
(run / "spirula" / "scene.ply").write_bytes(raw[:end] + v.tobytes())
thin_out(right, lambda v: v["x"] > -1, gain=0.7) # its training came out darker
out, r = combine([left, right], rooms=["Kitchen", "Kitchen"], colour="match")
assert r.returncode == 0, r.stderr
v, colour = splats(out)
lw, rw = colour[v["x"] < 0.3, 0], colour[(v["x"] > 3.7) & (v["x"] < 4.3), 0]
assert abs(np.median(lw) - np.median(rw)) < 0.01
assert abs(np.median(lw) - np.sqrt(0.6 * 0.42)) < 0.01 # in between: the room keeps its brightness
info = json.loads((out / "combine.json").read_text())
assert info["colour"] == "match" and info["rooms"]["Right"]["colour"][0] > 1.1
# the view-dependent part is scaled alike: seen from the side the two halves match, too
rest = lambda m: float(np.median(v["f_rest_0"][m]))
gl, gr = info["rooms"]["Left"]["colour"][0], info["rooms"]["Right"]["colour"][0]
assert abs(rest(v["x"] < 0.3) - 0.1 * gl) < 0.002 and abs(rest((v["x"] > 3.7) & (v["x"] < 4.3)) - 0.1 * gr) < 0.002
out, r = combine([left, right], rooms=["Kitchen", "Kitchen"], name="kept", colour="keep")
v, colour = splats(out)
assert abs(np.median(colour[v["x"] < 0.3, 0]) - 0.6) < 0.01
def test_colour_matching_leaves_alone_what_it_cannot_compare():
sys.path.insert(0, str(ROOT / "scripts"))
import combine as C
cover = np.zeros((10, 2)); cover[:, 0] = 5
colour = np.full((10, 2, 3), 0.5)
assert np.all(C.colour_gains(cover, colour) == 1) # nothing in common: unchanged
cover[:] = 5; colour[:, 1] = 0.05 # wildly different: limited
g = C.colour_gains(cover, colour, min_cubes=5)
assert g.max() <= C.GAIN[1] and g.min() >= C.GAIN[0]
def test_recordings_of_one_room_do_not_hide_a_walk_in():
"""Two rounds through the kitchen and the hallway's recording, which starts in the kitchen. Without
rooms the two kitchen rounds look like walking in to each other, and the hallway's real walk-in is not
recognised any more – it then claims part of the kitchen."""
k1 = synth.make_room(RUNS, "K1", "A", colour=RED)
k2 = synth.make_room(RUNS, "K2", "A", loop=(2.0, 1.5, 1.2, 0.8), colour=GREEN)
hall = synth.make_room(RUNS, "Hall", "B")
def hall_in_kitchen(rooms, name):
out, r = combine([k1, k2, hall], rooms=rooms, name=name)
assert r.returncode == 0, r.stderr
v, colour = splats(out)
inside = (v["x"] > 0.2) & (v["x"] < 3.6)
return int((inside & coloured(colour, HALL)).sum()), json.loads((out / "combine.json").read_text())
with_rooms, info = hall_in_kitchen(["Kitchen", "Kitchen", "Hallway"], "rooms")
assert info["rooms"]["Hall"]["walk_in_out_cameras"] > 10
assert with_rooms < 50
assert info["rooms"]["K1"]["walk_in_out_cameras"] == info["rooms"]["K2"]["walk_in_out_cameras"] == 0
without, info = hall_in_kitchen(None, "plain")
# the walk-in is not recognised; the hallway claims part of the kitchen – less than before 0.3, since a
# cube only goes to a recording that really has something there
assert info["rooms"]["Hall"]["walk_in_out_cameras"] == 0 and without > 2 * max(with_rooms, 5)
def test_rooms_need_one_name_per_recording():
left = synth.make_room(RUNS, "Left", "A", loop=(1.1, 1.5, 0.6, 0.8))
right = synth.make_room(RUNS, "Right", "A", loop=(2.9, 1.5, 0.6, 0.8))
for rooms in (["Kitchen"], ["Kitchen", ""], "Kitchen", ["Kitchen", 3]):
out, r = combine([left, right], rooms=rooms)
assert r.returncode != 0 and "--rooms" in r.stderr, rooms
def test_the_floor_plan_shows_a_room_of_several_recordings_once():
left = synth.make_room(RUNS, "Left", "A", loop=(1.1, 1.5, 0.6, 0.8), error=(0.0, (0, 0, 0)))
right = synth.make_room(RUNS, "Right", "A", loop=(2.9, 1.5, 0.6, 0.8), error=(0.0, (0, 0, 0)))
hall = synth.make_room(RUNS, "Hall", "B", error=(0.0, (0, 0, 0)))
r = subprocess.run([sys.executable, str(ROOT / "scripts" / "floorplan.py"), str(BASE / "plan.png"),
f"{left}=Kitchen", f"{right}=Kitchen", f"{hall}=Hallway"], capture_output=True, text=True)
assert r.returncode == 0, r.stderr
info = json.loads(r.stdout.strip().splitlines()[-1])
sizes = {s["label"]: s["size"] for s in info["scenes"]}
assert set(sizes) == {"Kitchen", "Hallway"}
assert abs(max(sizes["Kitchen"]) - 4) < 0.3 and abs(min(sizes["Kitchen"]) - 3) < 0.3 # the whole 4 x 3 m room
# ---------------------------------------------------------------- rooms in the web interface (server)
from conftest import server, wait_job # noqa: E402
def make_home():
"""The kitchen in two recordings (left and right half), the hallway in one – one session."""
synth.make_room(RUNS, "Left", "A", name="Kitchen left", loop=(1.1, 1.5, 0.6, 0.8), colour=RED,
recording="2026-10-12_16-40-00_stereo.mp4")
synth.make_room(RUNS, "Right", "A", name="Kitchen right", loop=(2.9, 1.5, 0.6, 0.8), colour=GREEN,
recording="2026-10-12_16-41-00_stereo.mp4")
synth.make_room(RUNS, "Hall", "B", name="Hallway", recording="2026-10-12_16-43-00_stereo.mp4")
def test_a_room_and_a_home_made_of_it(api):
make_home()
status, r = api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
assert status == 200 and r["slug"] == "Kitchen"
status, r = api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
assert status == 200
by = {g["slug"]: g for g in api.get("/api/groups")[1]["groups"]}
assert (by["Kitchen"]["room"], by["Kitchen"]["parent"], by["Kitchen"]["rooms"]) == (True, "Home", 1)
assert (by["Home"]["room"], by["Home"]["members"], by["Home"]["rooms"]) == (False, ["Kitchen", "Hall"], 2)
assert by["Home"]["scenes"] == ["Left", "Right", "Hall"]
assert api.get("/api/groups")[1]["suggestion"] is None # everything is grouped
def test_what_a_room_and_a_home_may_hold(api):
make_home()
synth.make_room(RUNS, "Other", "A", recording="2026-10-03_19-00-00_stereo.mp4")
assert api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})[0] == 200
assert api.post("/api/group", {"name": "Flat", "members": "Other"})[0] == 200
for body in ({"name": "Room2", "members": "Kitchen,Hall", "room": True}, # a room holds recordings only
{"name": "Home", "members": "Flat,Hall"}, # no group of groups of groups
{"name": "Self", "members": "Self"}):
status, r = api.post("/api/group", body)
assert status == 400 and r["error"]["code"] == "err.bad_request", body
assert api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})[0] == 200
status, r = api.post("/api/group", {"name": "Home 2", "members": "Kitchen"}) # a room in one home only
assert status == 400 and r["error"]["code"] == "err.in_other_group"
status, r = api.post("/api/group", {"slug": "Kitchen", "name": "Kitchen", "members": "Left,Right"})
assert status == 400 and r["error"]["code"] == "err.room_in_group" # inside a home it stays a room
def test_a_renamed_room_stays_in_its_home(api):
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
assert api.post("/api/group", {"slug": "Kitchen", "name": "Cooking", "members": "Left,Right", "room": True})[0] == 200
by = {g["slug"]: g for g in api.get("/api/groups")[1]["groups"]}
assert by["Home"]["members"] == ["Cooking", "Hall"] and by["Cooking"]["parent"] == "Home"
assert api.post("/api/delete", {"slug": "Cooking"})[0] == 200 # dissolving the room
assert {g["slug"]: g for g in api.get("/api/groups")[1]["groups"]}["Home"]["members"] == ["Hall"]
def test_putting_a_home_together_from_its_recordings(api, monkeypatch):
monkeypatch.setenv("COLOUR", "keep")
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
assert server.start_combine({"slug": "Home", "full": False}) is None
js = wait_job()
assert js["result"] == "ok", js["tail"]
info = json.loads((RUNS / "Home" / "combine.json").read_text())
assert {l: x["room"] for l, x in info["rooms"].items()} == {
"Kitchen left": "Kitchen", "Kitchen right": "Kitchen", "Hallway": "Hallway"}
v, colour = splats(RUNS / "Home")
assert coloured(colour, RED)[v["x"] < 0.3].mean() > 0.9 and coloured(colour, GREEN)[(v["x"] > 3.7) & (v["x"] < 3.95)].mean() > 0.9
home = {g["slug"]: g for g in api.get("/api/groups")[1]["groups"]}["Home"]
assert home["result"] and not home["result"]["stale"]
# the room alone, too
assert server.start_combine({"slug": "Kitchen", "full": False}) is None
assert wait_job()["result"] == "ok"
info = json.loads((RUNS / "Kitchen" / "combine.json").read_text())
assert {x["room"] for x in info["rooms"].values()} == {"Kitchen"}
def test_a_home_without_rooms_is_put_together_as_before(api, monkeypatch):
make_home()
api.post("/api/group", {"name": "Home", "members": "Right,Hall"}) # (the left half does not reach the door)
seen = {}
real = server.subprocess.Popen
def popen(cmd, *a, **kw):
if any("combine.sh" in str(c) for c in cmd):
seen["rooms"] = kw["env"].get("ROOMS")
return real(cmd, *a, **kw)
monkeypatch.setattr(server.subprocess, "Popen", popen)
assert server.start_combine({"slug": "Home", "full": False}) is None
assert wait_job()["result"] == "ok"
assert seen == {"rooms": None}
def test_the_floor_plan_of_a_home_names_rooms(api):
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
info = server.floorplan(["Left", "Right", "Hall"], "classic", "en", "", "Home")[1]
assert sorted(s["label"] for s in info["scenes"]) == ["Hallway", "Kitchen"]
def test_a_homes_scale_reaches_the_rooms_and_their_recordings(api):
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
assert api.post("/api/scale", {"slug": "Home", "scale": "87", "with_scenes": True})[0] == 200
scales = {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}
assert scales == {"Left": 87, "Right": 87, "Hall": 87}
assert {g["slug"]: g["scale"] for g in api.get("/api/groups")[1]["groups"]} == {"Home": 87, "Kitchen": 87}
def test_a_renamed_room_gets_a_floor_plan_with_its_new_name(api, monkeypatch):
"""The floor plan (and the preview images made from it) carries the room's name: drawn again."""
monkeypatch.setenv("COLOUR", "keep")
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
assert server.start_combine({"slug": "Kitchen", "full": False}) is None
assert wait_job()["result"] == "ok"
plan = RUNS / "Kitchen" / "plan.png"
before = plan.read_bytes()
status, r = api.post("/api/group", {"slug": "Kitchen", "name": "Big kitchen", "members": "Left,Right", "room": True})
assert status == 200, r
plan = RUNS / r["slug"] / "plan.png"
assert plan.read_bytes() != before and not (plan.parent / "plan.new.png").exists()
assert (plan.parent / f"{r['slug'].lower()}.gallery.png").exists()
def test_two_rooms_of_the_same_name_stay_two_rooms(api):
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
server.write_meta("Hall", name="Kitchen") # e.g. renamed by mistake
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
assert sorted(server.group_scenes("Home")) == [("Hall", "Kitchen (2)"), ("Left", "Kitchen"), ("Right", "Kitchen")]
def test_the_preview_plan_of_a_home_names_its_rooms(api, monkeypatch):
"""combine.sh gets the rooms (ROOMS) and labels the floor plan of the preview by them."""
monkeypatch.setenv("COLOUR", "keep")
make_home()
api.post("/api/group", {"name": "Kitchen", "members": "Left,Right", "room": True})
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hall"})
assert server.start_combine({"slug": "Home", "full": False}) is None
assert wait_job()["result"] == "ok"
plan = json.loads((RUNS / "Home" / "plan.json").read_text())
assert sorted(s["label"] for s in plan["scenes"]) == ["Hallway", "Kitchen"]
+151
View File
@@ -0,0 +1,151 @@
"""Scale for models (e.g. H0 = 1:87): floor plan and measurements in the original's size, and the
original's size in Gaussian XR (worldScale).
The model is the synthetic 4 x 3 m room built 87 times smaller, so the expected numbers are simply
those of the full-size room."""
import json
import subprocess
import sys
import time
import pytest
from conftest import BASE, ROOT, RUNS, server
import synth
H0 = 87
def plan(*runs, ratio=None):
out = runs[0].parent / "plan.png"
cmd = [sys.executable, str(ROOT / "scripts" / "floorplan.py"), str(out), *[f"{r}={r.name}" for r in runs]]
if ratio:
cmd += ["--ratio", str(ratio)]
r = subprocess.run(cmd, capture_output=True, text=True)
assert r.returncode == 0, r.stderr
return json.loads(r.stdout.strip().splitlines()[-1])
def test_a_model_has_the_floor_plan_of_the_original():
full = plan(synth.make_room(BASE / "full", "Room", "A", error=(0.0, (0, 0, 0))))
model = plan(synth.make_room(BASE / "model", "Room", "A", error=(0.0, (0, 0, 0)), scale=1 / H0), ratio=H0)
assert model["scenes"][0]["size"] == pytest.approx(full["scenes"][0]["size"], abs=0.05) # 4 x 3 m
assert model["pixels_per_metre"] == pytest.approx(full["pixels_per_metre"], rel=0.01) # measuring in metres
assert model["ratio"] == H0
def test_a_model_without_its_scale_still_draws():
info = plan(synth.make_room(BASE / "model", "Room", "A", error=(0.0, (0, 0, 0)), scale=1 / H0))
assert info["ratio"] == 1
@pytest.mark.parametrize("scale", ["0.5", "2000", "abc", "nan"])
def test_scales_out_of_range_are_refused(api, scale):
synth.make_room(RUNS, "Layout", "A")
status, r = api.post("/api/scale", {"slug": "Layout", "scale": scale})
assert status == 400 and r["error"]["code"] in ("err.range", "err.bad_value")
def test_setting_and_clearing_the_scale_of_a_scene(api):
synth.make_room(RUNS, "Layout", "A")
assert api.post("/api/scale", {"slug": "Layout", "scale": "87", "xr": True})[0] == 200
s = {x["slug"]: x for x in api.get("/api/scenes")[1]}["Layout"]
assert (s["scale"], s["scale_xr"]) == (87, True)
assert api.post("/api/scale", {"slug": "Layout", "scale": "1", "xr": True})[0] == 200
s = {x["slug"]: x for x in api.get("/api/scenes")[1]}["Layout"]
assert (s["scale"], s["scale_xr"]) == (1, False) # original size: nothing to enlarge in XR
assert api.post("/api/scale", {"slug": "Nope", "scale": "87"})[0] == 400
def test_a_group_passes_its_scale_on(api):
synth.make_room(RUNS, "Station", "A", recording="2026-10-12_16-40-00_stereo.mp4")
synth.make_room(RUNS, "Yard", "B", recording="2026-10-12_16-43-00_stereo.mp4")
synth.make_room(RUNS, "Other", "A", recording="2026-10-03_19-00-00_stereo.mp4")
server.save_group("", "Layout", "Station,Yard")
assert api.post("/api/scale", {"slug": "Layout", "scale": "160", "with_scenes": True})[0] == 200
scales = {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}
assert scales == {"Station": 160, "Yard": 160, "Other": 1}
assert api.get("/api/groups")[1]["groups"][0]["scale"] == 160
api.post("/api/scale", {"slug": "Layout", "scale": "87"}) # without its scenes this time
assert {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}["Station"] == 160
def test_the_floor_plan_endpoint_measures_in_the_originals_metres(api):
synth.make_room(RUNS, "Layout", "A", error=(0.0, (0, 0, 0)), scale=1 / H0)
api.post("/api/scale", {"slug": "Layout", "scale": str(H0)})
status, png = api.get("/api/floorplan?slugs=Layout&style=classic")
assert status == 200 and png[:4] == b"\x89PNG"
full = plan(synth.make_room(BASE / "full", "Room", "A", error=(0.0, (0, 0, 0))))
info = server.floorplan(["Layout"], "classic", "en", "")[1]
assert info["pixels_per_metre"] == pytest.approx(full["pixels_per_metre"], rel=0.01)
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
@pytest.mark.parametrize("xr,expected", [(True, H0), (False, 1.0)])
def test_world_scale_on_the_frame(api, frame, xr, expected):
run = synth.make_room(RUNS, "Layout", "A")
api.post("/api/scale", {"slug": "Layout", "scale": str(H0), "xr": xr})
api.post("/api/upload", {"slug": "Layout"})
assert wait_upload("Layout")["ok"]
sent = json.loads((frame / server.FRAME_SCENES / "20_Layout" / "layout.xrSceneTransform.json").read_text())
assert sent["worldScale"] == expected
assert sent["transform"] == json.loads((run / "scene.xrSceneTransform.json").read_text())["transform"]
assert json.loads((run / "scene.xrSceneTransform.json").read_text())["worldScale"] == 1.0 # the original stays
def test_changing_the_scale_updates_the_frame(api, frame):
synth.make_room(RUNS, "Layout", "A")
api.post("/api/upload", {"slug": "Layout"})
wait_upload("Layout")
assert api.post("/api/scale", {"slug": "Layout", "scale": "45", "xr": True, "update_frame": True})[0] == 200
assert wait_upload("Layout")["ok"]
sent = json.loads((frame / server.FRAME_SCENES / "20_Layout" / "layout.xrSceneTransform.json").read_text())
assert sent["worldScale"] == 45
def test_a_tour_of_a_model_is_walkable_in_both_versions():
synth.make_room(RUNS, "Station", "A", recording="2026-10-12_16-40-00_stereo.mp4")
synth.make_room(RUNS, "Yard", "B", recording="2026-10-12_16-43-00_stereo.mp4")
server.save_group("", "Layout", "Station,Yard")
server.set_scale("Layout", H0, xr=True, members=True, update_frame=False)
assert server.start_combine({"slug": "Layout", "full": True}) is None
from conftest import wait_job
assert wait_job()["result"] == "ok"
tour = RUNS / "Layout"
assert json.loads((tour / "layout.xrSceneTransform.json").read_text())["worldScale"] == H0
assert json.loads((tour / "full" / "layout-full.xrSceneTransform.json").read_text())["worldScale"] == H0
def test_the_floor_plan_of_a_group_in_the_groups_scale(api):
synth.make_room(RUNS, "Station", "A", error=(0.0, (0, 0, 0)), scale=1 / H0)
server.save_group("", "Layout", "Station")
api.post("/api/scale", {"slug": "Layout", "scale": str(H0)}) # only on the group, not passed on
assert {x["slug"]: x["scale"] for x in api.get("/api/scenes")[1]}["Station"] == 1
full = plan(synth.make_room(BASE / "full", "Room", "A", error=(0.0, (0, 0, 0))))
info = server.floorplan(["Station"], "classic", "en", "", "Layout")[1]
assert info["ratio"] == H0
assert info["scenes"][0]["size"] == pytest.approx(full["scenes"][0]["size"], abs=0.05) # 4 x 3 m
status, png = api.get("/api/floorplan?slugs=Station&style=classic&group=Layout")
assert status == 200 and png[:4] == b"\x89PNG"
assert server.floorplan(["Station"], "classic", "en", "", "Station")["code"] == "err.bad_request" # not a group
def test_a_floor_plan_of_scenes_in_different_scales_is_refused(api):
synth.make_room(RUNS, "Station", "A", recording="2026-10-12_16-40-00_stereo.mp4")
synth.make_room(RUNS, "Yard", "B", recording="2026-10-12_16-43-00_stereo.mp4")
server.save_group("", "Layout", "Station,Yard")
api.post("/api/scale", {"slug": "Station", "scale": "87"})
api.post("/api/scale", {"slug": "Yard", "scale": "160"})
status, r = api.get("/api/floorplan?slugs=Station,Yard&group=Layout")
assert status == 400 and r["error"]["code"] == "err.mixed_scale"
api.post("/api/scale", {"slug": "Layout", "scale": "87"}) # one scale for the group decides
assert api.get("/api/floorplan?slugs=Station,Yard&style=classic&group=Layout")[0] == 200
+91
View File
@@ -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()
+153
View File
@@ -0,0 +1,153 @@
"""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", "/api/versions", "/api/scale",
"/api/frame/remove", "/api/frame/move", "/api/recording/delete", "/api/version/delete",
"/api/download/cancel"]
@pytest.mark.parametrize("path", ENDPOINTS)
@pytest.mark.parametrize("field", ["name", "new", "slug", "path", "what", "members", "key", "align", "quality",
"existing", "partition", "version", "sizes", "scales", "folder", "names", "place",
"where"])
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()
+71
View File
@@ -0,0 +1,71 @@
"""Setup hints (0.4.1, from issues #1 and #2): typos in the config, splat-transform only on the shell's PATH,
an Arcturus pipeline that PyAV 19 breaks."""
import os
import subprocess
import pytest
from conftest import ROOT, server
from splat_workshop import config
UNPINNED = '''#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "av>=18.1",
# "numpy",
# ]
# ///
import av
av.open("x", metadata_errors="strict")
'''
def test_a_typo_in_the_config_is_named_with_the_right_spelling():
unknown = config.unknown_keys({"paths": {"splat-transform": "/x", "pipeline": "/p"}, "frmae": {"host": ""}})
assert unknown == [("paths.splat-transform", "paths.splat_transform"), ("frmae", "frame")]
c = config.Config(config._merge(config.DEFAULTS, {}), None, unknown)
keys = [k for k, _ in c.problems()]
assert keys[:2] == ["setup.unknown_key", "setup.unknown_key"]
def test_a_pipeline_that_pyav_19_breaks_is_found(tmp_path):
for name in config.PYAV_FILES:
(tmp_path / name).write_text(UNPINNED)
assert config.pyav_unpinned(tmp_path) == list(config.PYAV_FILES)
(tmp_path / "av1_colmap.py").write_text(UNPINNED.replace('"av>=18.1"', '"av>=18.1,<19"'))
assert config.pyav_unpinned(tmp_path) == ["av1_to_3dgs.py"]
c = config.Config(config._merge(config.DEFAULTS, {"paths": {"pipeline": str(tmp_path)}}), None)
hint = dict(c.problems())["setup.pyav"]
assert hint["files"] == "av1_to_3dgs.py"
def test_a_conversion_failing_on_pyav_says_why():
out = server.parse_output("Traceback ...\nTypeError: open() got an unexpected keyword argument 'metadata_errors'\n")
assert out["info"]["hint"] == "hint.pyav"
assert "hint" not in server.parse_output("Traceback ...\nValueError: something else\n")["info"]
@pytest.mark.parametrize("existing", [False, True])
def test_install_gives_the_full_path_of_splat_transform(tmp_path, existing):
"""install.sh finds splat-transform on the shell's PATH: a new config gets the full path, an existing one
a line to add – the app, started from the desktop, does not see that PATH."""
home, xdg, bin_ = tmp_path / "home", tmp_path / "xdg", tmp_path / "bin"
for d in (home, xdg, bin_):
d.mkdir()
(bin_ / "splat-transform").write_text("#!/bin/sh\n")
(bin_ / "splat-transform").chmod(0o755)
conf = xdg / "splat-workshop" / "config.toml"
if existing:
conf.parent.mkdir()
conf.write_text('[paths]\nsplat-transform = "/typo"\n')
env = {**os.environ, "HOME": str(home), "XDG_CONFIG_HOME": str(xdg), "XDG_DATA_HOME": str(tmp_path / "data"),
"PATH": f"{bin_}:{os.environ['PATH']}"}
env.pop("SPLAT_WORKSHOP_CONFIG", None)
r = subprocess.run([str(ROOT / "install.sh")], env=env, capture_output=True, text=True, timeout=120)
assert r.returncode == 0, r.stderr
line = f'splat_transform = "{bin_ / "splat-transform"}"'
if existing:
assert line in r.stdout and line not in conf.read_text()
assert 'Unknown setting "paths.splat-transform"' in r.stdout and '"paths.splat_transform"' in r.stdout
else:
assert line in conf.read_text() and "written to the config" in r.stdout
+138
View File
@@ -0,0 +1,138 @@
"""Standby when everything is done (0.3): after the last job – also after the parts of a long recording
and putting them together – a minute's notice, then the PC suspends. The fake systemctl only notes it."""
import os
import time
import pytest
from conftest import recording, server, tools_called, wait_for_tool, wait_job
@pytest.fixture(autouse=True)
def quick(monkeypatch):
monkeypatch.setattr(server, "STANDBY_GRACE", 0)
yield
server.release_awake()
def suspends():
return [c for c in tools_called() if c[0] == "systemctl"]
def test_standby_after_the_job(api):
assert server.start_job({"path": str(recording()), "name": "Room", "quality": "fast", "upload": False}) is None
assert api.post("/api/standby", {"on": True})[1]["on"]
server.standby_tick(busy=True)
assert not suspends() and server.standby_status()["at"] is None # nothing while it runs
wait_job()
server.standby_tick(busy=False) # all done: the notice
assert server.standby_status()["at"] and not suspends()
assert any("standby" in n[-1].lower() for n in wait_for_tool("notify-send"))
server.standby_tick(busy=False) # the minute is up
assert suspends() == [["systemctl", "suspend"]]
assert server.standby_status() == {"on": False, "at": None, "error": None} # once only
server.standby_tick(busy=False)
assert len(suspends()) == 1
def test_work_starting_again_stops_the_countdown():
server.set_standby(True)
server.standby_tick(busy=False)
assert server.standby_status()["at"]
server.standby_tick(busy=True) # e.g. the next part started
assert server.standby_status()["at"] is None and server.standby_status()["on"]
assert not suspends()
def test_switched_off_in_time():
server.set_standby(True)
server.standby_tick(busy=False)
assert server.set_standby(False)["on"] is False
server.standby_tick(busy=False)
assert not suspends()
def test_cancelling_a_job_means_someone_is_there():
os.environ["FAKE_PIPELINE"] = "slow"
server.start_job({"path": str(recording()), "name": "Room", "quality": "fast", "upload": False})
server.set_standby(True)
time.sleep(1)
server.cancel_job()
wait_job(30)
server.standby_tick(busy=False)
server.standby_tick(busy=False)
assert not suspends() and not server.standby_status()["on"]
def test_a_refused_standby_is_reported():
os.environ["FAKE_SUSPEND_FAIL"] = "1"
server.set_standby(True)
server.standby_tick(busy=False)
server.standby_tick(busy=False)
assert "Steam" in server.standby_status()["error"]
end = time.time() + 5 # notifications are sent in the background
while not any("Steam" in c[-1] for c in tools_called() if c[0] == "notify-send") and time.time() < end:
time.sleep(0.1)
assert any("Steam" in c[-1] for c in tools_called() if c[0] == "notify-send")
def test_kept_awake_between_jobs_and_let_go_after(monkeypatch):
"""One lock of the server spans the few seconds between two jobs; it goes a little after the last."""
monkeypatch.setattr(server, "can_inhibit", lambda: True)
monkeypatch.setattr(server, "keep_awake", lambda cmd, why: cmd) # the lock is a process: here `sleep`
monkeypatch.setattr(server, "AWAKE_AFTER", 0.5)
server.hold_awake(True)
p = server.awake["proc"]
assert p and p.poll() is None
server.hold_awake(False) # between two jobs: still held
assert p.poll() is None
server.hold_awake(True)
assert server.awake["proc"] is p # the same lock, not a new one
server.hold_awake(False)
time.sleep(0.6)
server.hold_awake(False)
assert p.poll() is not None and server.awake["proc"] is None
def test_one_notice_after_a_whole_chain_of_parts():
"""Between two parts, and between the last part and putting them together, nothing announces the
standby: the watcher's tick that starts the next part reports it as running."""
os.environ["FAKE_PIPELINE"] = "room"
assert server.start_parts({"path": str(recording("2026-10-14_16-57-54_stereo.mp4")), "name": "Living room",
"quality": "high", "parts": 2, "upload": False}) is None
server.set_standby(True)
was, ticks = True, 0
while ticks < 10:
wait_job()
running = server.watch_once(was)
server.standby_tick(running)
ticks += 1
if not running:
break
assert server.standby_status()["at"] is None
was = running
assert server.job_status(tail=False)["kind"] == "combine"
server.standby_tick(False)
for p in server.notes:
p.wait(10)
notices = [c for c in tools_called() if c[0] == "notify-send" and "standby" in c[-1].lower()]
assert len(notices) == 1 and suspends() == [["systemctl", "suspend"]]
def test_the_own_lock_goes_before_the_standby(monkeypatch):
monkeypatch.setattr(server, "can_inhibit", lambda: True)
monkeypatch.setattr(server, "keep_awake", lambda cmd, why: cmd)
server.hold_awake(True)
lock = server.awake["proc"]
held = []
real = server.subprocess.run
def run(cmd, *a, **kw):
if cmd[:2] == ["systemctl", "suspend"]:
held.append(lock.poll() is None)
return real(cmd, *a, **kw)
monkeypatch.setattr(server.subprocess, "run", run)
server.set_standby(True)
server.standby_tick(False)
server.standby_tick(False)
assert held == [False] and server.awake["proc"] is None
+220
View File
@@ -0,0 +1,220 @@
"""Versions of a scene or tour (scripts/versions.sh): several sizes and scales side by side, each its own
scene – also on the Frame, where every version gets its own folder in the gallery."""
import json
import os
import time
import pytest
from conftest import RUNS, TRASH, recording, server, tools_called, wait_job
import synth
def room(slug="Room", **kw):
run = synth.make_room(RUNS, slug, "A", name=slug, **kw)
with open(run / "spirula" / "scene.ply", "rb") as f:
total = next(int(l.split()[-1]) for l in f if l.startswith(b"element vertex"))
return run, total
def make(slug, sizes, scales="1"):
assert server.start_versions({"slug": slug, "sizes": sizes, "scales": scales}) is None
js = wait_job()
assert js["result"] == "ok", js["tail"]
return {v["key"]: v for v in server.versions_of(slug)}
def test_versions_in_several_sizes():
run, total = room()
versions = make("Room", "10000,20000,all")
assert set(versions) == {"0.01m", "0.02m", "all"}
assert [versions[k]["splats"] for k in ("0.01m", "0.02m", "all")] == [10000, 20000, total]
assert versions["0.01m"]["label"] == "Room · 0.01 M" and versions["all"]["label"] == "Room · all splats"
reduced = [c for c in tools_called() if c[0] == "splat-transform" and "--decimate" in c]
assert sorted(c[c.index("--decimate") + 1] for c in reduced) == ["10000", "20000"] # "all" is not reduced
for key in versions:
v = run / "versions" / key
assert (v / "scene.sog").exists() and (v / f"room-{key}.sceneinfo.png").exists()
assert (v / f"room-{key}.gallery.png").exists() # the scene's preview
assert json.loads((v / "scene.xrSceneTransform.json").read_text())["worldScale"] == 1.0
assert server.scenes()[0]["versions"] == 3
def test_a_size_above_the_scene_is_all_of_it():
run, total = room()
versions = make("Room", str(total * 2))
assert list(versions.values())[0]["splats"] == total
def test_versions_in_the_originals_size(api):
run, _ = room()
api.post("/api/scale", {"slug": "Room", "scale": "87"})
versions = make("Room", "10000", "1,87")
assert set(versions) == {"0.01m", "0.01m-1x87"}
assert versions["0.01m-1x87"]["label"] == "Room · 0.01 M · 1:87"
worlds = {k: json.loads((run / "versions" / k / "scene.xrSceneTransform.json").read_text())["worldScale"]
for k in versions}
assert worlds == {"0.01m": 1.0, "0.01m-1x87": 87.0}
def test_versions_side_by_side_on_the_frame(api, frame):
room()
make("Room", "10000,20000")
for key in ("0.01m", "0.02m"):
assert api.post("/api/upload", {"slug": "Room", "version": key})[0] == 200
end = time.time() + 60
while (u := server.upload_status(f"Room#{key}")) and u["running"] and time.time() < end:
time.sleep(0.2)
assert server.upload_status(f"Room#{key}")["ok"]
scenes = frame / server.FRAME_SCENES
assert sorted(p.name for p in scenes.iterdir()) == ["20_Room-0.01m", "21_Room-0.02m"]
assert sorted(p.name for p in (scenes / "21_Room-0.02m").iterdir()) == [
"room-0.02m.gallery.png", "room-0.02m.sceneinfo.png", "room-0.02m.sog", "room-0.02m.xrSceneTransform.json"]
folders = json.loads((RUNS / "Room.ui.json").read_text())["version_folders"]
assert folders == {"0.01m": "20_Room-0.01m", "0.02m": "21_Room-0.02m"}
versions = {v["key"]: v for v in server.versions_of("Room")}
assert versions["0.01m"]["on_frame"] and versions["0.02m"]["on_frame"]
assert api.post("/api/upload", {"slug": "Room", "version": "9m"})[1]["error"]["code"] == "err.no_version"
def test_deleting_a_version(api):
run, _ = room()
make("Room", "10000,20000")
assert api.post("/api/version/delete", {"slug": "Room", "version": "0.01m"})[0] == 200
assert set(v["key"] for v in server.versions_of("Room")) == {"0.02m"}
assert (TRASH / "0.01m").exists() and (run / "spirula" / "scene.ply").exists()
assert api.post("/api/version/delete", {"slug": "Room", "version": "../spirula"})[0] == 400
def test_versions_follow_a_renamed_scene(api):
room("Kitchen")
make("Kitchen", "10000")
assert api.post("/api/rename", {"slug": "Kitchen", "new": "Küche"})[0] == 200
v = RUNS / "Kueche" / "versions" / "0.01m"
assert (v / "kueche-0.01m.sceneinfo.png").exists() and not (v / "kitchen-0.01m.sceneinfo.png").exists()
assert [x["key"] for x in server.versions_of("Kueche")] == ["0.01m"]
def test_a_version_older_than_its_scene():
run, _ = room()
make("Room", "10000")
assert not server.versions_of("Room")[0]["stale"]
time.sleep(0.05)
os.utime(run / "spirula" / "scene.ply") # the scene was computed again
assert server.versions_of("Room")[0]["stale"]
@pytest.mark.parametrize("body,code", [
({"sizes": "5"}, "err.range"), ({"sizes": "abc"}, "err.bad_value"), ({"sizes": ""}, "err.no_versions"),
({"sizes": "10000", "scales": "0.5"}, "err.range"), ({"slug": "Nope", "sizes": "10000"}, "err.no_scene"),
])
def test_invalid_versions_are_refused(body, code):
room()
assert server.start_versions({"slug": "Room", **body})["code"] == code
def test_versions_of_a_tour():
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")
server.save_group("", "Flat", "Kitchen,Hallway")
assert server.start_combine({"slug": "Flat", "full": False}) is None
assert wait_job()["result"] == "ok"
versions = make("Flat", "10000,all")
assert set(versions) == {"0.01m", "all"}
assert versions["all"]["splats"] == server.groups()["groups"][0]["result"]["splats"]
assert server.groups()["groups"][0]["versions"] == 2
def test_one_job_at_a_time():
room()
os.environ["FAKE_PIPELINE"] = "slow"
rec = recording()
assert server.start_job({"path": str(rec), "name": "Other", "quality": "fast", "upload": False}) is None
time.sleep(1)
assert server.start_versions({"slug": "Room", "sizes": "10000"})["code"] == "err.job_busy"
def upload(api, slug, key):
assert api.post("/api/upload", {"slug": slug, "version": key})[0] == 200
end = time.time() + 60
while (u := server.upload_status(f"{slug}#{key}")) and u["running"] and time.time() < end:
time.sleep(0.2)
assert server.upload_status(f"{slug}#{key}")["ok"]
def test_taking_a_version_off_the_frame_removes_only_that(api, frame):
room()
make("Room", "10000,20000")
upload(api, "Room", "0.01m")
upload(api, "Room", "0.02m")
scenes, backup = frame / server.FRAME_SCENES, frame / server.FRAME_BACKUP
(scenes / "04_BurtLakeHouse").mkdir() # an example scene of Gaussian XR
(scenes / "30_Room-0.01m-old").mkdir() # someone else's folder with a similar name
(backup / "20_Room-0.01m").mkdir(parents=True) # a copy left by version 0.2.3 or older
(backup / "21_Room-0.02m").mkdir()
assert api.post("/api/frame/remove", {"slug": "Room", "version": "0.01m"})[0] == 200
assert sorted(p.name for p in scenes.iterdir()) == ["04_BurtLakeHouse", "21_Room-0.02m", "30_Room-0.01m-old"]
assert not (backup / "20_Room-0.01m").exists() and (backup / "21_Room-0.02m").exists()
assert "0.01m" not in json.loads((RUNS / "Room.ui.json").read_text())["version_folders"]
v = {x["key"]: x for x in server.versions_of("Room")}
assert v["0.01m"]["on_frame"] is False and v["0.02m"]["on_frame"] # still on the PC, off the Frame
status, r = api.post("/api/frame/remove", {"slug": "Room", "version": "0.01m"})
assert status == 400 and r["error"]["code"] == "err.not_on_frame_version"
@pytest.mark.parametrize("version", ["", "..", "../0.02m", ".hidden"])
def test_taking_off_the_frame_only_with_a_proper_version(api, frame, version):
room()
assert api.post("/api/frame/remove", {"slug": "Room", "version": version})[0] == 400
def test_taking_off_a_frame_that_is_off(api, frame):
room()
make("Room", "10000")
upload(api, "Room", "0.01m")
os.environ["FAKE_FRAME_OFF"] = "1"
status, r = api.post("/api/frame/remove", {"slug": "Room", "version": "0.01m"})
assert status == 400 and r["error"]["code"] == "err.frame_offline"
os.environ.pop("FAKE_FRAME_OFF")
assert (frame / server.FRAME_SCENES / "20_Room-0.01m").exists()
def test_versions_sh_refuses_odd_version_names():
import subprocess
from conftest import ROOT
run, _ = room()
for spec in ("../x|10000|1|x", ".hidden|10000|1|x", "a/b|10000|1|x"):
r = subprocess.run([str(ROOT / "scripts" / "versions.sh"), str(run), spec], capture_output=True, text=True)
assert r.returncode == 1 and "Unusable version name" in r.stderr, spec
assert not (run / "x").exists() and not (run / "versions").exists()
def test_an_old_copy_of_a_version_is_recognised_by_its_folder(api, frame):
room()
make("Room", "10000") # not copied by this version: no version_folders
d = frame / server.FRAME_STEAM / "21_Room-0.01m"
d.mkdir()
(d / "room-0.01m.gallery.png").write_bytes(b"preview")
server.frame_cache["t"] = 0
assert server.versions_of("Room")[0]["on_frame"] is True
g = {x["folder"]: x for x in api.get("/api/frame/gallery?refresh=1")[1]["scenes"]}["21_Room-0.01m"]
assert (g["kind"], g["slug"], g["version"], g["place"]) == ("version", "Room", "0.01m", "steam")
def test_nothing_is_moved_or_removed_on_the_frame_while_copying(api, frame):
room()
make("Room", "10000")
d = frame / server.FRAME_STEAM / "20_Room-0.01m" # an old copy, being copied again right now
d.mkdir()
(d / "room-0.01m.gallery.png").write_bytes(b"preview")
os.environ["FAKE_SCP_SLOW"] = "1"
assert api.post("/api/upload", {"slug": "Room", "version": "0.01m"})[0] == 200
time.sleep(0.5)
for path, body in (("/api/frame/move", {}), ("/api/frame/remove", {"folder": "20_Room-0.01m"}),
("/api/version/delete", {"slug": "Room", "version": "0.01m"})):
status, r = api.post(path, body)
assert status == 400 and r["error"]["code"] in ("err.upload_busy", "err.scene_busy"), path
end = time.time() + 30
while (u := server.upload_status("Room#0.01m")) and u["running"] and time.time() < end:
time.sleep(0.2)
assert server.upload_status("Room#0.01m")["ok"]
+342 -4
View File
@@ -15,10 +15,15 @@ const I18N = {
"setup.hint": "Settings are in {file}. Restart Splat Workshop after changing them.",
"setup.pipeline": "Arcturus pipeline not found in {path} (av1_to_3dgs.py). Download it from arcturus.vision/apps.",
"setup.spirula": "Spirula Studio not found at {path}. Build it as described in the pipeline README.",
"setup.splat_transform": "splat-transform not found – install it with: npm install -g @playcanvas/splat-transform",
"setup.splat_transform": "splat-transform not found. Install it (without root: npm install -g --prefix ~/.local @playcanvas/splat-transform) and put its full path into {file}, e.g. splat_transform = \"/home/you/.local/bin/splat-transform\" – started from the desktop, Splat Workshop does not see a PATH set in .bashrc or .zshrc.",
"setup.unknown_key": "Unknown setting “{key}” in {file} – did you mean “{suggestion}”? (Splat Workshop ignores it.)",
"setup.unknown_key.plain": "Unknown setting “{key}” in {file} – Splat Workshop ignores it.",
"setup.pyav": "The Arcturus pipeline in {path} will fail with “unexpected keyword argument 'metadata_errors'” (PyAV 19). In the # /// script header of {files}, change \"av>=18.1\" to \"av>=18.1,<19\" – see Troubleshooting in docs/arcturus-setup.md.",
"setup.tool": "{tool} is not installed.",
// job card
"job.cancel": "Cancel", "job.dismiss": "Hide", "job.log": "Log",
"job.standby": "💤 Standby when done", "job.standby.in": "💤 Standby in {s} s",
"job.standby.title": "When everything is done (also the following parts, putting them together, copying to the Frame), the PC goes to standby after a minute's notice. Cancelling a job switches this off.",
"job.psnr": "Image quality (PSNR) during training",
"job.sub": "{rec} · {q} · {target} · for {time}", "job.toFrame": "→ Frame", "job.pcOnly": "PC only",
"job.left": " · about {time} left",
@@ -31,9 +36,16 @@ const I18N = {
"res.cancelled": "Cancelled. Start again with the same name and choose “Resume” to continue.",
"res.upload_failed": "The scene is finished, but copying to the Frame did not work (Frame off?). Use “To the Frame” in the list of finished scenes once it is on.",
"res.failed": "Failed – the last lines of the log show why.",
"hint.pyav": "Failed: the Arcturus pipeline does not work with PyAV 19 (“unexpected keyword argument 'metadata_errors'”). In the # /// script header of av1_to_3dgs.py and av1_colmap.py, change \"av>=18.1\" to \"av>=18.1,<19\", then resume – see Troubleshooting in docs/arcturus-setup.md.",
"confirm.cancel": "Really cancel the conversion? With “Resume” you can continue later from the last saved state.",
// step 1
"s1.title": "Choose a recording", "tab.frame": "On the Frame", "tab.local": "On this PC",
"dl.all": "Fetch all ({n} · {size})", "dl.all.title": "Fetch every recording that is not on this PC yet, one after the other",
"dl.sel": "Fetch selection ({n} · {size})", "dl.sel.title": "Fetch the ticked recordings, one after the other",
"dl.pick": "Fetch this one too", "dl.queued": "waiting", "dl.cancel": "Cancel", "dl.cancelled": "Fetching cancelled",
"dl.progress": "Fetching {i} of {n} from the Frame · {done} of {total}",
"dl.done": "{n} recording(s) fetched – you can switch the Frame off", "dl.failed": "{n} recording(s) could not be fetched",
"err.no_download": "Nothing is being fetched",
"checkAll": "Check all", "checkAll.title": "Check all recordings on this PC that have not been checked yet",
"pick": "Choose file …", "refresh.title": "Reload list", "loading": "Loading …",
"frame.unreachable": "The Frame is not reachable – is it on and in the same network?",
@@ -96,15 +108,36 @@ const I18N = {
"t.frame": "Gaussian XR gallery on the Frame", "t.frame.sub": "Copied automatically after computing, with preview image and name plate",
"t.pc": "This PC only", "t.pc.sub": "The result stays in the runs folder – e.g. to look at it in Spirula",
"t.offline": "The Frame is not reachable right now. Computing works anyway – it just has to be on at the end.",
"split": "Long recording",
"split.parts": "Split the video into parts",
"split.parts.pro": "more detail: {rate} instead of {whole} image pairs per second ({pairs} per part of {len})",
"split.parts.con": "takes about {n} times as long; afterwards the parts are put together into one room automatically (small seams are possible where they meet)",
"split.whole": "Convert in one piece",
"split.whole.pro": "done sooner, one conversion, no seams between parts",
"split.whole.con": "only {rate} instead of {full} image pairs per second – the pipeline takes at most 500 per recording, so fine details get lost more easily",
"est.parts": "{n} parts × about {frames} image pairs · up to {maxg} M splats · {iter}k steps · together about {mins} min",
"started.parts": "Started – {n} parts one after the other, then they become one room",
"job.sub.part": "Part {i} of {n} of “{room}” · {q} · {time}",
"err.parts_too_short": "The parts would be shorter than {secs} s – choose fewer parts",
"err.recording_has_parts": "{n} scenes (e.g. “{name}”) were cut from this recording – it is their original: with it, the cuts can be made again (start over, resume).",
"rdel.parts.confirm": "Delete it anyway? The finished scenes stay; without the original their parts can no longer be cut again.",
"start": "Start conversion", "est.none": "Choose a recording first.", "est.busy": "A conversion is already running.",
"est": "{frames} image pairs · {maxg} M splats · {iter}k steps · <b>about {mins} min</b> <span class=\"faint\">(rough estimate)</span>",
"started": "Conversion started", "unusable": "This recording can't be used like this",
// exists dialog
"exists.title": "This scene already exists",
"exists.text": "There is already a run named “{name}”. Resume uses its saved intermediate results (e.g. after cancelling); Start over discards it.",
"exists.noresume.renamed": "The recording of “{name}” was renamed since, so this run can only be started over.",
"exists.noresume.images": "The training images of “{name}” were deleted, so this run can only be started over.",
"exists.text": "There is already a run named “{name}”. Resume uses its saved intermediate results (e.g. after cancelling). Start over computes it again; a finished scene is only replaced once the new one is done.",
"offline.title": "Splat Workshop is not responding.", "offline.text": "The page reconnects by itself once it runs again (tray icon or ./splat-workshop). A running conversion continues regardless.",
"exists.cancel": "Cancel", "exists.resume": "Resume", "exists.fresh": "Start over",
// scenes
"scenes.title": "Finished scenes", "scenes.none": "No finished scenes yet.",
"unfin.title": "Cancelled runs", "unfin.hint": "Runs that did not finish. Resume continues where possible, otherwise delete them to free the space.",
"unfin.stopped": "stopped at: {state}", "unfin.logonly": "only a log file left", "unfin.empty": "no progress", "unfin.resume": "▶ Resume", "unfin.resumed": "Started",
"unfin.restart": "↻ Start over", "unfin.restart.title": "Stopped before the camera positions were complete, so it can only start from the beginning (same settings)",
"unfin.delete.title": "Delete run", "unfin.confirm": "Move the cancelled run “{name}” ({size}) to the trash?",
"err.not_resumable": "This run cannot be resumed (any more) – delete it and start over",
"sc.uploading": "Copying to the Frame …", "sc.uploadFailed": "Copying failed – Frame on?",
"sc.onFrame": "✓ in the Frame's gallery", "sc.notOnFrame": "Not on the Frame yet", "sc.frameOffline": "Frame not reachable",
"sc.toFrame": "To the Frame", "sc.reupload": "Copy again",
@@ -113,7 +146,143 @@ const I18N = {
"sc.folder": "Folder", "sc.spirula": "Spirula", "sc.spirula.title": "Open the scene in Spirula Studio",
"sc.spirula.starting": "Starting Spirula …", "sc.uploadStarted": "Copying to the Frame …",
"sc.splats": "{n} M splats",
"sc.plan": "Floor plan",
"rdel.frame": "🗑 Delete from the Frame", "rdel.pc": "🗑 Delete from this PC",
"rdel.confirm.frame": "Delete “{name}” ({size}) from the Frame? This cannot be undone there – the copy on this PC stays.",
"rdel.confirm.frame.only": "“{name}” ({size}) is NOT on this PC yet! Delete it from the Frame anyway? It is then gone for good.",
"rdel.confirm.pc": "Move “{name}” ({size}) on this PC to the trash? Finished scenes stay.",
"rdel.confirm.pc.only": "Move “{name}” ({size}) to the trash? It is no longer on the Frame – the trash then holds the only copy.",
"rdel.done.frame": "“{name}” deleted from the Frame", "rdel.done.pc": "“{name}” moved to the trash",
"err.recording_fetching": "The recording is being fetched from the Frame right now",
"err.recording_needed": "“{name}” from this recording is not finished yet and still needs it",
"ren.button": "✎ Rename", "ren.ok": "Rename", "ren.cancel": "Cancel", "ren.done": "Renamed",
"ren.rec.title": "Rename recording",
"ren.rec.hint": "Renames the file on this PC. The recording date is kept; the copy on the Frame keeps its name.",
"ren.scene.title": "Rename scene",
"ren.scene.hint": "Changes the name in Splat Workshop, the scene folder on this PC and the name plate in the Gaussian XR gallery. On the Frame the scene stays in its folder.",
"err.scene_name_taken": "There already is a scene with that name",
"ren.frame": "Also update the name plate on the Frame",
"ren.frame.started": "Renamed – updating the Frame …",
"sc.disk": "{size} on disk", "sc.clean": "🧹", "sc.clean.title": "Show disk usage and clean up",
"sc.delete": "🗑", "sc.delete.title": "Delete scene",
"del.confirm": "Move the scene “{name}” ({size}) to the trash?\n\nIt can be restored from there until the trash is emptied.{frame}",
"del.frame": "\nThe copy on the Frame is not touched.", "del.done": "“{name}” moved to the trash",
"err.trash_failed": "Could not move it to the trash",
"clean.title": "Clean up “{name}”",
"clean.intro": "The scene itself, the floor plan and the Spirula viewer keep working in any case.",
"clean.intro.group": "For all {n} scenes of the group together. The scenes, the tour, the floor plan and the Spirula viewer keep working in any case.",
"clean.confirm.group": "Delete the {what} of all {n} scenes in “{name}” ({size})? This cannot be undone.",
"grp.clean.title": "Disk usage of all scenes in the group, clean up",
"clean.total": "On disk: {size}", "clean.total.group": "The scenes take: {size}",
"clean.temp": "Intermediate data", "clean.temp.text": "Training checkpoint and structure-from-motion working files. Not needed once the scene is finished.",
"clean.images": "Training images", "clean.images.text": "The images extracted from the recording. Only needed to train this scene again with other settings – that then starts from the recording again.",
"clean.delete": "Delete", "clean.empty": "nothing left", "clean.close": "Close",
"clean.confirm": "Delete the {what} of “{name}” ({size})? This cannot be undone.",
"clean.done": "{size} freed",
"plan.title": "Floor plan – {name}", "plan.title.all": "{name} – floor plan",
"plan.pretty": "Pretty", "plan.classic": "Classic", "plan.save": "Save image", "plan.close": "Close", "plan.drawing": "Drawing the floor plan … (a few seconds)", "plan.file": "floor-plan",
"plan.note.pretty": "Top view of the splats with the ceiling cut away. Bright lines are walls, the dotted line is the path you walked with the camera (circle = start). Sizes are approximate.",
"plan.note.classic": "Density of the splats between knee and head height – walls and furniture light up. Sizes are approximate.",
"grp.new": "+ Group", "grp.new.tip": "Put scenes together in a group, e.g. all rooms of a flat",
"grp.new.title": "New group", "grp.edit.title": "Edit group", "grp.name": "Name", "grp.pick": "Scenes in this group",
"grp.noFree": "Every scene is already in a group – a scene can only be in one.", "grp.save": "Save", "grp.cancel": "Cancel",
"grp.room": "One room made of several recordings",
"grp.room.hint": "e.g. two short rounds through the living room. When put together, every part comes from the recording that saw it best. A room can be part of a home group.",
"grp.room.recordings": "{n} recordings", "grp.home.count": "{rooms} rooms from {n} recordings",
"grp.room.combine": "Put the room together …",
"err.room_in_group": "It is a room of “{group}” – take it out there first",
"grp.rooms": "{n} scenes", "grp.show": "▸ Show {n} scenes", "grp.hide": "▾ Hide",
"grp.show.rooms": "▸ Show {n} rooms", "grp.show.recordings": "▸ Show {n} recordings",
"grp.combine": "Put the tour together …", "grp.recombine": "Put together again …", "grp.busy": "Being put together right now …",
"grp.none": "Not put together yet.",
"grp.tour": "Tour: Frame {frame} ({fsize}){full}", "grp.tour.full": " · full {n} ({size})",
"grp.seams": "Overlaps match within {med} cm (median), at most {max} cm · correction {align}",
"grp.stale": "The scenes have changed since – put it together again",
"grp.edit": "✎ Edit", "grp.delete.title": "Dissolve group",
"grp.delete.confirm": "Dissolve the group “{name}”? The scenes in it stay.{tour}",
"grp.delete.tour": "\n\nThe tour that was put together ({size}) is moved to the trash.", "grp.deleted": "Group dissolved",
"grp.suggest": "These {n} scenes from {date} were recorded right after each other: {names}. Group them?",
"grp.suggest.yes": "Create group …", "grp.suggest.no": "No thanks", "grp.suggest.name": "Tour {date}",
"cmb.title": "Put “{name}” together",
"cmb.hint": "All rooms of one recording session become one scene: a full version for the PC and a smaller one for the Frame. Afterwards the group shows how well the overlaps match – try another correction any time; the previous result stays until the new one is done.",
"cmb.hint.room": "All recordings of this room become one scene: a full version for the PC and a smaller one for the Frame. Every place comes from a recording that really has it, and all recordings are brought to one brightness. Afterwards the room shows how well the recordings match – try another correction any time; the previous result stays until the new one is done.",
"cmb.align": "Correction",
"cmb.align.frame": "A – Frame only", "cmb.align.frame.text": "As the pipeline delivers the rooms (aligned on their first image). For comparison.",
"cmb.align.images": "B – all images (recommended)", "cmb.align.images.text": "Every room fitted to the Frame's movement over all its images. Safe, no surprises.",
"cmb.align.shift": "C – B + shift the overlaps", "cmb.align.shift.text": "Also shifts the rooms by a few centimetres so all overlaps match best together.",
"cmb.align.rigid": "D – B + shift and turn", "cmb.align.rigid.text": "Like C, may also turn the rooms very slightly. Often the most exact, but can make an overlap with little in common worse.",
"cmb.part": "Borders between the recordings",
"cmb.part.view": "Through doors (recommended)", "cmb.part.view.text": "Every place comes from the recording that saw it from close by and looking at it. Walking in and out does not count.",
"cmb.part.nearest": "Nearest camera", "cmb.part.nearest.text": "Every place comes from the recording whose camera was closest. Can cut through walls.",
"cmb.size": "Size of the Frame version",
"cmb.size.2": "2 M splats – smoother", "cmb.size.35": "3.5 M splats – recommended", "cmb.size.5": "5 M splats – more detail",
"cmb.full": "Also make the full version (for Gaussian XR on the PC)",
"cmb.mem": "Memory: about {frame} GB, with the full version about {full} GB. Free now: {avail} GB.",
"cmb.mem.low": "Not enough free memory – close other programs, or put it together without the full version.",
"cmb.upload": "Copy to the Frame afterwards", "cmb.start": "Put together", "cmb.started": "Putting it together …",
"cphase.0": "Aligning", "cphase.1": "Assembling", "cphase.2": "Reducing", "cphase.3": "Converting",
"job.sub.combine": "{n} scenes · correction {align} · {target} · for {time}",
"job.sub.combine.room": "{n} recordings of one room · correction {align} · {target} · for {time}",
"err.in_other_group": "“{group}” already contains one of these scenes",
"err.combine_too_few": "Putting together needs at least two finished scenes",
"scale.button.title": "Scale – for models, e.g. a model railway", "scale.title": "Scale of “{name}”",
"scale.hint": "For models: floor plan, sizes and measurements then show the size of the original (H0: 10 cm in the model = 8.7 m).",
"scale.original": "Original size", "scale.custom": "Own scale",
"scale.xr": "Walk through it in Gaussian XR in the original's size (not tested on the Frame yet)",
"scale.members": "Also for all scenes in this group", "scale.frame": "Update it on the Frame too",
"scale.line": "Scale {ratio}", "scale.line.xr": "original size in XR", "scale.done": "Scale saved",
"ver.button": "Versions ({n})", "ver.button.title": "Versions with fewer splats for the Frame or all splats for the PC – several side by side",
"ver.title": "Versions of “{name}” ({n} splats)",
"ver.hint": "Every version is its own scene and gets its own place in the Frame's gallery – e.g. 0.75 M and 2 M side by side to compare.",
"ver.new": "New version", "ver.custom": "Own size", "ver.million": "million splats",
"ver.scales": "Scale", "ver.walk": "walk through it ({ratio})", "ver.all": "all splats",
"ver.measured": "Measured on a Steam Frame (Gaussian XR, 10/2026): 0.75 M ≈ 36 fps, 1 M ≈ 35 fps, 1.5 M ≈ 29 fps, 2 M ≈ 24 fps, 5 M ≈ 14 fps.",
"ver.for": "For", "ver.for.frame": "🥽 the Frame (headset)", "ver.for.pc": "🖥️ the PC (all splats, e.g. Spirula Studio)",
"ver.size.head": "Size", "ver.fps.0_75": "≈ {fps} fps", "ver.fps.1": "≈ {fps} fps, clearly sharper", "ver.fps.1_5": "≈ {fps} fps, a little unsteady", "ver.fps.2": "≈ {fps} fps",
"ver.plain": "Also make it without cleaning up, to compare",
"ver.clean.none": "not cleaned up", "ver.slow": "{n} M splats: on the Frame only about 14 fps",
"ver.template": "As template", "ver.template.title": "Put this version's settings into the form below – change one slider and make the next one",
"ver.template.done": "Settings taken over – change what you want to compare",
"ver.replaces": "{what} (replaces the existing one)", "ver.same.all": "All splats without cleaning up is the scene itself – open it in Spirula from the scene, or set a slider.",
"ver.makes.1": "Makes:", "ver.makes.n": "Makes {n} versions:", "ver.make.1": "Make version", "ver.make.n": "Make {n} versions",
"ver.show": "▸ Show {n} versions", "ver.first": "+ Versions …", "ver.show.1": "▸ Show 1 version", "ver.new.button": "+ New version …", "ver.hide": "▾ Hide versions", "ver.of": "Versions of “{name}”",
"ver.pick": "Enter a size (at least 0.01 million)", "ver.started": "Making versions …",
"ver.none": "No versions yet.", "ver.stale": "older than the scene", "ver.delete": "Delete version",
"ver.confirm": "Move the version “{name}” ({size}) to the trash? A copy on the Frame is not touched.",
"job.sub.versions": "{n} versions · for {time}", "err.no_version": "This version does not exist",
"err.no_versions": "Pick at least one size",
"ver.unframe": "Off the Frame", "ver.unframe.title": "Remove this version from the Frame's gallery (it stays on the PC)",
"ver.unframe.confirm": "Remove “{name}” from the Frame? Its folder in the gallery and the backup copy on the Frame are deleted; the version stays on this PC.",
"ver.unframed": "“{name}” removed from the Frame", "err.not_on_frame_version": "This version is not on the Frame",
"err.frame_offline": "The Frame is not reachable", "err.frame_failed": "The Frame refused",
"stab.pc": "On this PC", "stab.frame": "On the Frame",
"fg.hint": "Everything in Gaussian XR's gallery on the Frame. Taking something off the Frame does not touch this PC.",
"fg.loading": "Asking the Frame …", "fg.empty": "The gallery on the Frame is empty.",
"fg.kind.scene": "Scene", "fg.kind.tour": "Tour", "fg.kind.version": "Version", "fg.kind.other": "Not from Splat Workshop",
"fg.remove": "Off the Frame", "fg.remove.title": "Delete this scene from the Frame's gallery",
"fg.confirm": "Take “{name}” ({size}) off the Frame? It stays on this PC and can be copied again any time.",
"fg.confirm.other": "“{name}” ({size}) was not made with Splat Workshop – e.g. one of Gaussian XR's examples. Delete it from the Frame anyway? Steam brings the examples back with “verify integrity of game files”.",
"fg.removed": "“{name}” taken off the Frame",
"fg.place.own": "Own scenes", "fg.place.own.title": "Documents/GaussianXR on the Frame – Steam updates leave it alone",
"fg.place.steam": "Gaussian XR folder", "fg.place.steam.title": "Gaussian XR's own gallery folder – a Steam update or “verify files” can reset it",
"fg.move.hint": "{n} scene(s) from Splat Workshop are still in Gaussian XR's own folder, where a Steam update can delete them.",
"fg.move": "Move to own scenes", "fg.moved": "Moved to the own scenes on the Frame",
"ver.recommended": "(recommended)",
"ver.clean": "Clean up", "ver.clean.off": "off",
"ver.clean.hint": "Experimental. The scene itself stays as it is; only this version is cleaned up – compare it with one without.",
"ver.clean.ceiling": "Ceiling", "ver.clean.ceiling.title": "A white ceiling gives the reconstruction nothing to hold on to: it stays thin, with a dark veil under it. Takes out the veil (deeper with more strength; dark things within 50 cm of a wall stay) and what lies above the ceiling, and puts in a closed ceiling in its own colour.",
"ver.clean.floor": "Floor", "ver.clean.floor.title": "Fills holes in the floor in the colour of the floor around them (more holes count with more strength) and takes out what lies under the floor.",
"ver.clean.haze": "Haze", "ver.clean.haze.title": "Takes out big, nearly transparent blobs floating in the room. Long thin things (bars, cables, wires) are no haze and stay. At full strength smaller and less transparent blobs go, too.",
"ver.clean.outside": "Edges", "ver.clean.outside.title": "Takes out what lies well outside the room: beyond the walls, above the ceiling, under the floor – from 1 m beyond them to 30 cm at full strength. Also the view through a window.",
"err.mixed_scale": "These scenes have different scales – set one scale for the group", "err.plan_failed": "The floor plan could not be drawn", "sc.replacing": "Being computed again – stays until the new one is done", "err.plan_busy": "Other floor plans are being drawn right now, try again in a moment",
"plan.measure": "📏 Measure", "plan.measure.title": "Measure distances in the floor plan",
"plan.measure.hint": "Click to set points · right-click removes the last one · horizontal distances, about ±5 cm",
"plan.measure.undo": "Undo", "plan.measure.reset": "Reset",
"plan.measure.start": "Click the first point", "plan.measure.noscale": "Scale unknown – restart Splat Workshop", "plan.measure.total": "Total {d}",
// errors from the server
"err.name_taken": "There already is a recording with that name", "err.recording_busy": "The recording is being converted or checked right now",
"err.scene_busy": "The scene is being computed or copied right now",
"err.recording_unfinished": "“{name}” from this recording is not finished yet – rename it once the scene is done (otherwise it can't be resumed)",
"err.not_local": "The recording is not on this PC", "err.download_busy": "A download is already running",
"err.job_busy": "A conversion is already running", "err.unknown_recording": "Unknown recording",
"err.bad_name": "Please enter a name (letters/digits)", "err.bad_quality": "Unknown quality",
@@ -135,9 +304,14 @@ const I18N = {
"setup.hint": "Die Einstellungen stehen in {file}. Nach Änderungen die Splat-Werkstatt neu starten.",
"setup.pipeline": "Arcturus-Pipeline nicht gefunden in {path} (av1_to_3dgs.py). Download: arcturus.vision/apps.",
"setup.spirula": "Spirula Studio nicht gefunden unter {path}. Bauen wie in der Pipeline-README beschrieben.",
"setup.splat_transform": "splat-transform fehlt – installieren mit: npm install -g @playcanvas/splat-transform",
"setup.splat_transform": "splat-transform nicht gefunden. Installieren (ohne root: npm install -g --prefix ~/.local @playcanvas/splat-transform) und den vollen Pfad in {file} eintragen, z. B. splat_transform = \"/home/du/.local/bin/splat-transform\" – vom Desktop gestartet sieht die Splat-Werkstatt keinen PATH aus .bashrc oder .zshrc.",
"setup.unknown_key": "Unbekannte Einstellung „{key}“ in {file} – meintest du „{suggestion}“? (Die Splat-Werkstatt ignoriert sie.)",
"setup.unknown_key.plain": "Unbekannte Einstellung „{key}“ in {file} – die Splat-Werkstatt ignoriert sie.",
"setup.pyav": "Die Arcturus-Pipeline in {path} wird mit „unexpected keyword argument 'metadata_errors'“ scheitern (PyAV 19). Im # /// script-Kopf von {files} \"av>=18.1\" in \"av>=18.1,<19\" ändern – siehe Troubleshooting in docs/arcturus-setup.md.",
"setup.tool": "{tool} ist nicht installiert.",
"job.cancel": "Abbrechen", "job.dismiss": "Ausblenden", "job.log": "Protokoll",
"job.standby": "💤 Danach in den Standby", "job.standby.in": "💤 Standby in {s} s",
"job.standby.title": "Wenn alles fertig ist (auch die weiteren Teile, das Zusammensetzen, das Kopieren auf die Frame), geht der PC nach einer Minute Vorwarnung in den Standby. Abbrechen eines Jobs schaltet das aus.",
"job.psnr": "Bildqualität (PSNR) im Training",
"job.sub": "{rec} · {q} · {target} · seit {time}", "job.toFrame": "→ Frame", "job.pcOnly": "nur PC",
"job.left": " · noch ca. {time}",
@@ -150,8 +324,15 @@ const I18N = {
"res.cancelled": "Abgebrochen. Mit gleichem Namen erneut starten und „Fortsetzen“ wählen, um weiterzumachen.",
"res.upload_failed": "Die Szene ist fertig gerechnet, aber das Kopieren auf die Frame hat nicht geklappt (Frame aus?). In der Liste der fertigen Szenen „Auf die Frame“ nutzen, sobald sie an ist.",
"res.failed": "Fehlgeschlagen – die letzten Zeilen im Protokoll zeigen warum.",
"hint.pyav": "Fehlgeschlagen: Die Arcturus-Pipeline läuft nicht mit PyAV 19 („unexpected keyword argument 'metadata_errors'“). Im # /// script-Kopf von av1_to_3dgs.py und av1_colmap.py \"av>=18.1\" in \"av>=18.1,<19\" ändern und fortsetzen – siehe Troubleshooting in docs/arcturus-setup.md.",
"confirm.cancel": "Umwandlung wirklich abbrechen? Mit „Fortsetzen“ geht es später ab dem letzten Zwischenstand weiter.",
"s1.title": "Aufnahme wählen", "tab.frame": "Auf der Frame", "tab.local": "Auf dem PC",
"dl.all": "Alle holen ({n} · {size})", "dl.all.title": "Alle Aufnahmen, die noch nicht auf dem PC sind, nacheinander holen",
"dl.sel": "Auswahl holen ({n} · {size})", "dl.sel.title": "Die angehakten Aufnahmen nacheinander holen",
"dl.pick": "Diese auch holen", "dl.queued": "wartet", "dl.cancel": "Abbrechen", "dl.cancelled": "Holen abgebrochen",
"dl.progress": "Hole {i} von {n} von der Frame · {done} von {total}",
"dl.done": "{n} Aufnahme(n) geholt – du kannst die Frame abschalten", "dl.failed": "{n} Aufnahme(n) konnten nicht geholt werden",
"err.no_download": "Es wird nichts geholt",
"checkAll": "Alle prüfen", "checkAll.title": "Alle Aufnahmen auf dem PC prüfen, die noch nicht geprüft sind",
"pick": "Datei wählen …", "refresh.title": "Liste neu laden", "loading": "Lade …",
"frame.unreachable": "Die Frame ist nicht erreichbar – ist sie an und im selben Netz?",
@@ -212,13 +393,34 @@ const I18N = {
"t.frame": "Gaussian-XR-Galerie auf der Frame", "t.frame.sub": "Wird nach dem Rechnen automatisch kopiert, mit Vorschaubild und Namensschild",
"t.pc": "Nur auf dem PC", "t.pc.sub": "Ergebnis bleibt im runs-Ordner – z. B. zum Ansehen in Spirula",
"t.offline": "Die Frame ist gerade nicht erreichbar. Gerechnet wird trotzdem – sie muss nur am Ende eingeschaltet sein.",
"split": "Lange Aufnahme",
"split.parts": "Video aufteilen in Teile:",
"split.parts.pro": "mehr Detail: {rate} statt {whole} Bildpaare pro Sekunde ({pairs} je Teil à {len})",
"split.parts.con": "dauert etwa {n}-mal so lange; danach werden die Teile automatisch zu einem Raum zusammengesetzt (wo sie sich treffen, sind kleine Nähte möglich)",
"split.whole": "Video nicht aufteilen",
"split.whole.pro": "schneller fertig, eine einzige Umwandlung, keine Nähte zwischen Teilen",
"split.whole.con": "nur {rate} statt {full} Bildpaare pro Sekunde – die Pipeline nimmt höchstens 500 pro Aufnahme, feine Details gehen eher verloren",
"est.parts": "{n} Teile × etwa {frames} Bildpaare · bis {maxg} Mio. Splats · {iter}k Schritte · zusammen etwa {mins} min",
"started.parts": "Gestartet – {n} Teile nacheinander, danach werden sie ein Raum",
"job.sub.part": "Teil {i} von {n} von „{room}“ · {q} · {time}",
"err.parts_too_short": "Die Teile wären kürzer als {secs} s – weniger Teile wählen",
"err.recording_has_parts": "Aus dieser Aufnahme sind {n} Szenen geschnitten (z. B. „{name}“) – sie ist ihr Original: Damit lassen sich die Schnitte wiederholen (neu rechnen, fortsetzen).",
"rdel.parts.confirm": "Trotzdem löschen? Die fertigen Szenen bleiben; ohne das Original lassen sich ihre Teile aber nicht mehr neu schneiden.",
"start": "Umwandlung starten", "est.none": "Erst eine Aufnahme wählen.", "est.busy": "Es läuft schon eine Umwandlung.",
"est": "{frames} Bildpaare · {maxg} Mio. Splats · {iter}k Schritte · <b>ca. {mins} min</b> <span class=\"faint\">(grobe Schätzung)</span>",
"started": "Umwandlung gestartet", "unusable": "Die Aufnahme ist so nicht verwendbar",
"exists.title": "Diese Szene gibt es schon",
"exists.text": "Unter „{name}“ liegt schon ein Lauf. Fortsetzen nutzt die vorhandenen Zwischenstände (z. B. nach einem Abbruch); Neu rechnen verwirft ihn.",
"exists.noresume.renamed": "Die Aufnahme von „{name}“ wurde inzwischen umbenannt, dieser Lauf lässt sich daher nur neu rechnen.",
"exists.noresume.images": "Die Trainingsbilder von „{name}“ wurden gelöscht, dieser Lauf lässt sich daher nur neu rechnen.",
"exists.text": "Unter „{name}“ liegt schon ein Lauf. Fortsetzen nutzt die vorhandenen Zwischenstände (z. B. nach einem Abbruch). Neu rechnen fängt von vorn an; eine fertige Szene wird erst ersetzt, wenn die neue fertig ist.",
"offline.title": "Die Splat-Werkstatt antwortet nicht.", "offline.text": "Die Seite verbindet sich von selbst wieder, sobald sie läuft (Tray-Symbol oder ./splat-workshop). Eine laufende Umwandlung rechnet trotzdem weiter.",
"exists.cancel": "Abbrechen", "exists.resume": "Fortsetzen", "exists.fresh": "Neu rechnen",
"scenes.title": "Fertige Szenen", "scenes.none": "Noch keine fertigen Szenen.",
"unfin.title": "Abgebrochene Läufe", "unfin.hint": "Läufe, die nicht fertig geworden sind. Fortsetzen macht weiter, wo das geht – sonst löschen, um den Platz freizugeben.",
"unfin.stopped": "abgebrochen bei: {state}", "unfin.logonly": "nur noch eine Protokolldatei", "unfin.empty": "ohne Fortschritt", "unfin.resume": "▶ Fortsetzen", "unfin.resumed": "Gestartet",
"unfin.restart": "↻ Neu starten", "unfin.restart.title": "Vor dem Abschluss der Kamerapositionen abgebrochen, daher nur von vorn möglich (gleiche Einstellungen)",
"unfin.delete.title": "Lauf löschen", "unfin.confirm": "Abgebrochenen Lauf „{name}“ ({size}) in den Papierkorb verschieben?",
"err.not_resumable": "Dieser Lauf lässt sich nicht (mehr) fortsetzen – löschen und neu starten",
"sc.uploading": "Wird auf die Frame übertragen …", "sc.uploadFailed": "Übertragung fehlgeschlagen – Frame an?",
"sc.onFrame": "✓ in der Galerie der Frame", "sc.notOnFrame": "Noch nicht auf der Frame", "sc.frameOffline": "Frame nicht erreichbar",
"sc.toFrame": "Auf die Frame", "sc.reupload": "Erneut übertragen",
@@ -227,6 +429,142 @@ const I18N = {
"sc.folder": "Ordner", "sc.spirula": "Spirula", "sc.spirula.title": "Szene in Spirula Studio öffnen",
"sc.spirula.starting": "Spirula startet …", "sc.uploadStarted": "Übertrage auf die Frame …",
"sc.splats": "{n} Mio. Splats",
"sc.plan": "Grundriss",
"rdel.frame": "🗑 Von der Frame löschen", "rdel.pc": "🗑 Vom PC löschen",
"rdel.confirm.frame": "„{name}“ ({size}) von der Frame löschen? Dort lässt sich das nicht rückgängig machen – die Kopie auf dem PC bleibt.",
"rdel.confirm.frame.only": "„{name}“ ({size}) liegt noch NICHT auf dem PC! Trotzdem von der Frame löschen? Dann ist sie endgültig weg.",
"rdel.confirm.pc": "„{name}“ ({size}) auf dem PC in den Papierkorb verschieben? Fertige Szenen bleiben.",
"rdel.confirm.pc.only": "„{name}“ ({size}) in den Papierkorb verschieben? Auf der Frame liegt sie nicht mehr – der Papierkorb hat dann die einzige Kopie.",
"rdel.done.frame": "„{name}“ von der Frame gelöscht", "rdel.done.pc": "„{name}“ in den Papierkorb verschoben",
"err.recording_fetching": "Die Aufnahme wird gerade von der Frame geholt",
"err.recording_needed": "„{name}“ aus dieser Aufnahme ist noch nicht fertig und braucht sie noch",
"ren.button": "✎ Umbenennen", "ren.ok": "Umbenennen", "ren.cancel": "Abbrechen", "ren.done": "Umbenannt",
"ren.rec.title": "Aufnahme umbenennen",
"ren.rec.hint": "Benennt die Datei auf dem PC um. Das Aufnahmedatum bleibt erhalten; die Kopie auf der Frame behält ihren Namen.",
"ren.scene.title": "Szene umbenennen",
"ren.scene.hint": "Ändert den Namen in der Splat-Werkstatt, den Szenenordner auf dem PC und das Namensschild in der Gaussian-XR-Galerie. Auf der Frame bleibt die Szene in ihrem Ordner.",
"err.scene_name_taken": "Es gibt schon eine Szene mit diesem Namen",
"ren.frame": "Namensschild auch auf der Frame aktualisieren",
"ren.frame.started": "Umbenannt – aktualisiere die Frame …",
"sc.disk": "{size} auf der Platte", "sc.clean": "🧹", "sc.clean.title": "Speicherplatz anzeigen und aufräumen",
"sc.delete": "🗑", "sc.delete.title": "Szene löschen",
"del.confirm": "Szene „{name}“ ({size}) in den Papierkorb verschieben?\n\nVon dort lässt sie sich wiederherstellen, bis der Papierkorb geleert wird.{frame}",
"del.frame": "\nDie Kopie auf der Frame bleibt unberührt.", "del.done": "„{name}“ liegt im Papierkorb",
"err.trash_failed": "Konnte nicht in den Papierkorb verschoben werden",
"clean.title": "„{name}“ aufräumen",
"clean.intro": "Die Szene selbst, der Grundriss und der Spirula-Viewer funktionieren in jedem Fall weiter.",
"clean.intro.group": "Für alle {n} Szenen der Gruppe zusammen. Die Szenen, der Rundgang, der Grundriss und der Spirula-Viewer funktionieren in jedem Fall weiter.",
"clean.confirm.group": "{what} aller {n} Szenen in „{name}“ löschen ({size})? Das lässt sich nicht rückgängig machen.",
"grp.clean.title": "Speicherplatz aller Szenen der Gruppe anzeigen und aufräumen",
"clean.total": "Auf der Platte: {size}", "clean.total.group": "Die Szenen belegen: {size}",
"clean.temp": "Zwischenstände", "clean.temp.text": "Trainings-Zwischenstand und Arbeitsdateien der Kamerapositionen. Nach dem Abschluss nicht mehr nötig.",
"clean.images": "Trainingsbilder", "clean.images.text": "Die aus der Aufnahme gezogenen Bilder. Nur nötig, um diese Szene mit anderen Einstellungen neu zu trainieren – das beginnt dann wieder bei der Aufnahme.",
"clean.delete": "Löschen", "clean.empty": "nichts mehr da", "clean.close": "Schließen",
"clean.confirm": "{what} von „{name}“ löschen ({size})? Das lässt sich nicht rückgängig machen.",
"clean.done": "{size} freigegeben",
"plan.title": "Grundriss – {name}", "plan.title.all": "{name} – Grundriss",
"plan.pretty": "Hübsch", "plan.classic": "Klassisch", "plan.save": "Bild speichern", "plan.close": "Schließen", "plan.drawing": "Zeichne den Grundriss … (ein paar Sekunden)", "plan.file": "Grundriss",
"plan.note.pretty": "Draufsicht auf die Splats, Decke weggeschnitten. Helle Linien sind Wände, die gepunktete Linie ist dein Weg mit der Kamera (Kreis = Start). Maße sind ungefähr.",
"plan.note.classic": "Dichte der Splats zwischen Knie- und Kopfhöhe – Wände und Möbel leuchten. Maße sind ungefähr.",
"grp.new": "+ Gruppe", "grp.new.tip": "Szenen in einer Gruppe zusammenfassen, z. B. alle Räume einer Wohnung",
"grp.new.title": "Neue Gruppe", "grp.edit.title": "Gruppe bearbeiten", "grp.name": "Name", "grp.pick": "Szenen in dieser Gruppe",
"grp.noFree": "Alle Szenen sind schon in einer Gruppe – eine Szene kann nur in einer sein.", "grp.save": "Speichern", "grp.cancel": "Abbrechen",
"grp.room": "Ein Raum aus mehreren Aufnahmen",
"grp.room.hint": "z. B. zwei kurze Runden durchs Wohnzimmer. Beim Zusammensetzen kommt jeder Teil aus der Aufnahme, die ihn am besten gesehen hat. Ein Raum kann Teil einer Wohnungs-Gruppe sein.",
"grp.room.recordings": "{n} Aufnahmen", "grp.home.count": "{rooms} Räume aus {n} Aufnahmen",
"grp.room.combine": "Raum zusammensetzen …",
"err.room_in_group": "Das ist ein Raum von „{group}“ – erst dort herausnehmen",
"grp.rooms": "{n} Szenen", "grp.show": "▸ {n} Szenen zeigen", "grp.hide": "▾ Ausblenden",
"grp.show.rooms": "▸ {n} Räume zeigen", "grp.show.recordings": "▸ {n} Aufnahmen zeigen",
"grp.combine": "Rundgang zusammensetzen …", "grp.recombine": "Neu zusammensetzen …", "grp.busy": "Wird gerade zusammengesetzt …",
"grp.none": "Noch nicht zusammengesetzt.",
"grp.tour": "Rundgang: Frame {frame} ({fsize}){full}", "grp.tour.full": " · voll {n} ({size})",
"grp.seams": "Übergänge passen auf {med} cm (Median), höchstens {max} cm · Korrektur {align}",
"grp.stale": "Die Szenen haben sich seitdem geändert – neu zusammensetzen",
"grp.edit": "✎ Bearbeiten", "grp.delete.title": "Gruppe auflösen",
"grp.delete.confirm": "Gruppe „{name}“ auflösen? Die Szenen darin bleiben erhalten.{tour}",
"grp.delete.tour": "\n\nDer zusammengesetzte Rundgang ({size}) kommt in den Papierkorb.", "grp.deleted": "Gruppe aufgelöst",
"grp.suggest": "Diese {n} Szenen vom {date} wurden direkt nacheinander aufgenommen: {names}. Als Gruppe zusammenfassen?",
"grp.suggest.yes": "Gruppe anlegen …", "grp.suggest.no": "Nein danke", "grp.suggest.name": "Rundgang {date}",
"cmb.title": "„{name}“ zusammensetzen",
"cmb.hint.room": "Alle Aufnahmen dieses Raums werden zu einer Szene: eine volle Fassung für den PC und eine kleinere für die Frame. Jede Stelle kommt von einer Aufnahme, die dort wirklich etwas hat, und alle Aufnahmen werden auf eine Helligkeit gebracht. Danach zeigt der Raum, wie gut die Aufnahmen zueinander passen – du kannst jederzeit eine andere Korrektur ausprobieren; das alte Ergebnis bleibt, bis das neue fertig ist.",
"cmb.hint": "Alle Räume eines Aufnahmedurchgangs werden zu einer Szene: eine volle Fassung für den PC und eine kleinere für die Frame. Danach zeigt die Gruppe, wie gut die Übergänge passen – du kannst jederzeit eine andere Korrektur ausprobieren; das alte Ergebnis bleibt, bis das neue fertig ist.",
"cmb.align": "Korrektur",
"cmb.align.frame": "A – nur Frame", "cmb.align.frame.text": "So, wie die Pipeline die Räume liefert (am ersten Bild ausgerichtet). Zum Vergleich.",
"cmb.align.images": "B – an allen Bildern (empfohlen)", "cmb.align.images.text": "Jeder Raum an all seinen Bildern auf die Bewegung der Frame ausgerichtet. Sicher und ohne Überraschungen.",
"cmb.align.shift": "C – B + Übergänge verschieben", "cmb.align.shift.text": "Verschiebt die Räume zusätzlich um wenige Zentimeter, damit alle Übergänge gemeinsam am besten passen.",
"cmb.align.rigid": "D – B + verschieben und drehen", "cmb.align.rigid.text": "Wie C, darf die Räume zusätzlich minimal drehen. Oft am genauesten, kann aber einen Übergang mit wenig Gemeinsamem verschlechtern.",
"cmb.part": "Grenzen zwischen den Aufnahmen",
"cmb.part.view": "An Türen (empfohlen)", "cmb.part.view.text": "Jede Stelle kommt aus der Aufnahme, die sie aus der Nähe und mit Blick darauf gesehen hat. Hinein- und Hinausgehen zählt nicht.",
"cmb.part.nearest": "Nächste Kamera", "cmb.part.nearest.text": "Jede Stelle kommt aus der Aufnahme, deren Kamera am nächsten war. Kann Wände durchschneiden.",
"cmb.size": "Größe der Frame-Fassung",
"cmb.size.2": "2 Mio. Splats – flüssiger", "cmb.size.35": "3,5 Mio. Splats – empfohlen", "cmb.size.5": "5 Mio. Splats – detaillierter",
"cmb.full": "Auch die volle Fassung erzeugen (für Gaussian XR am PC)",
"cmb.mem": "Arbeitsspeicher: etwa {frame} GB, mit voller Fassung etwa {full} GB. Gerade frei: {avail} GB.",
"cmb.mem.low": "Zu wenig freier Arbeitsspeicher – andere Programme schließen oder ohne volle Fassung zusammensetzen.",
"cmb.upload": "Danach auf die Frame übertragen", "cmb.start": "Zusammensetzen", "cmb.started": "Wird zusammengesetzt …",
"cphase.0": "Ausrichten", "cphase.1": "Zusammenbauen", "cphase.2": "Verkleinern", "cphase.3": "Umwandeln",
"job.sub.combine": "{n} Szenen · Korrektur {align} · {target} · seit {time}",
"job.sub.combine.room": "{n} Aufnahmen eines Raums · Korrektur {align} · {target} · seit {time}",
"err.in_other_group": "„{group}“ enthält schon eine dieser Szenen",
"err.combine_too_few": "Zum Zusammensetzen braucht es mindestens zwei fertige Szenen",
"scale.button.title": "Maßstab – für Modelle, z. B. eine Modelleisenbahn", "scale.title": "Maßstab von „{name}“",
"scale.hint": "Für Modelle: Grundriss, Größen und Messwerte zeigen dann die Größe des Originals (H0: 10 cm im Modell = 8,7 m).",
"scale.original": "Originalgröße", "scale.custom": "Eigener Maßstab",
"scale.xr": "In Gaussian XR in Originalgröße begehbar (an der Frame noch nicht getestet)",
"scale.members": "Auch für alle Szenen dieser Gruppe", "scale.frame": "Auch auf der Frame aktualisieren",
"scale.line": "Maßstab {ratio}", "scale.line.xr": "in XR in Originalgröße", "scale.done": "Maßstab gespeichert",
"ver.button": "Fassungen ({n})", "ver.button.title": "Fassungen mit weniger Splats für die Frame oder allen Splats für den PC – mehrere nebeneinander",
"ver.title": "Fassungen von „{name}“ ({n} Splats)",
"ver.hint": "Jede Fassung ist eine eigene Szene und bekommt einen eigenen Platz in der Galerie der Frame – z. B. 0,75 Mio. und 2 Mio. nebeneinander zum Vergleichen.",
"ver.new": "Neue Fassung", "ver.custom": "Eigene Größe", "ver.million": "Mio. Splats",
"ver.scales": "Maßstab", "ver.walk": "begehbar ({ratio})", "ver.all": "alle Splats",
"ver.measured": "Gemessen auf einer Steam Frame (Gaussian XR, 10/2026): 0,75 Mio. ≈ 36 fps, 1 Mio. ≈ 35 fps, 1,5 Mio. ≈ 29 fps, 2 Mio. ≈ 24 fps, 5 Mio. ≈ 14 fps.",
"ver.for": "Wofür", "ver.for.frame": "🥽 die Frame (Brille)", "ver.for.pc": "🖥️ den PC (alle Splats, z. B. Spirula Studio)",
"ver.size.head": "Größe", "ver.fps.0_75": "≈ {fps} fps", "ver.fps.1": "≈ {fps} fps, deutlich schärfer", "ver.fps.1_5": "≈ {fps} fps, leicht unruhig", "ver.fps.2": "≈ {fps} fps",
"ver.plain": "Zusätzlich ohne Bereinigung erstellen, zum Vergleichen",
"ver.clean.none": "nicht bereinigt", "ver.slow": "{n} Mio. Splats: auf der Frame nur etwa 14 fps",
"ver.template": "Als Vorlage", "ver.template.title": "Die Einstellungen dieser Fassung ins Formular unten übernehmen – einen Regler ändern und die nächste erstellen",
"ver.template.done": "Einstellungen übernommen – ändere, was du vergleichen willst",
"ver.replaces": "{what} (ersetzt die vorhandene)", "ver.same.all": "Alle Splats ohne Bereinigung ist die Szene selbst – öffne sie bei der Szene in Spirula oder stell einen Regler.",
"ver.makes.1": "Erstellt:", "ver.makes.n": "Erstellt {n} Fassungen:", "ver.make.1": "Fassung erstellen", "ver.make.n": "{n} Fassungen erstellen",
"ver.show": "▸ {n} Fassungen zeigen", "ver.first": "+ Fassungen …", "ver.show.1": "▸ 1 Fassung zeigen", "ver.new.button": "+ Neue Fassung …", "ver.hide": "▾ Fassungen ausblenden", "ver.of": "Fassungen von „{name}“",
"ver.pick": "Eine Größe angeben (mindestens 0,01 Mio.)", "ver.started": "Erstelle Fassungen …",
"ver.none": "Noch keine Fassungen.", "ver.stale": "älter als die Szene", "ver.delete": "Fassung löschen",
"ver.confirm": "Fassung „{name}“ ({size}) in den Papierkorb verschieben? Eine Kopie auf der Frame bleibt unberührt.",
"job.sub.versions": "{n} Fassungen · seit {time}", "err.no_version": "Diese Fassung gibt es nicht",
"err.no_versions": "Mindestens eine Größe wählen",
"ver.unframe": "Von der Frame nehmen", "ver.unframe.title": "Diese Fassung aus der Galerie der Frame entfernen (auf dem PC bleibt sie)",
"ver.unframe.confirm": "„{name}“ von der Frame nehmen? Ihr Ordner in der Galerie und die Sicherungskopie auf der Frame werden gelöscht; auf dem PC bleibt die Fassung.",
"ver.unframed": "„{name}“ von der Frame genommen", "err.not_on_frame_version": "Diese Fassung liegt nicht auf der Frame",
"err.frame_offline": "Die Frame ist nicht erreichbar", "err.frame_failed": "Die Frame hat abgelehnt",
"stab.pc": "Auf dem PC", "stab.frame": "Auf der Frame",
"fg.hint": "Alles, was in der Galerie von Gaussian XR auf der Frame liegt. Von der Frame nehmen ändert nichts auf dem PC.",
"fg.loading": "Frage die Frame …", "fg.empty": "Die Galerie auf der Frame ist leer.",
"fg.kind.scene": "Szene", "fg.kind.tour": "Rundgang", "fg.kind.version": "Fassung", "fg.kind.other": "Nicht aus der Splat-Werkstatt",
"fg.remove": "Von der Frame nehmen", "fg.remove.title": "Diese Szene aus der Galerie der Frame löschen",
"fg.confirm": "„{name}“ ({size}) von der Frame nehmen? Auf dem PC bleibt sie und lässt sich jederzeit neu übertragen.",
"fg.confirm.other": "„{name}“ ({size}) stammt nicht aus der Splat-Werkstatt – z. B. ein Beispiel von Gaussian XR. Trotzdem von der Frame löschen? Steam stellt die Beispiele mit „Dateien auf Fehler überprüfen“ wieder her.",
"fg.removed": "„{name}“ von der Frame genommen",
"fg.place.own": "Eigene Szenen", "fg.place.own.title": "Documents/GaussianXR auf der Frame – Steam-Updates lassen den Ordner in Ruhe",
"fg.place.steam": "Ordner von Gaussian XR", "fg.place.steam.title": "Der Galerie-Ordner von Gaussian XR selbst – ein Steam-Update oder „Dateien überprüfen“ kann ihn zurücksetzen",
"fg.move.hint": "{n} Szene(n) aus der Werkstatt liegen noch im Ordner von Gaussian XR, wo ein Steam-Update sie löschen kann.",
"fg.move": "Zu den eigenen Szenen verschieben", "fg.moved": "Zu den eigenen Szenen auf der Frame verschoben",
"ver.recommended": "(empfohlen)",
"ver.clean": "Bereinigen", "ver.clean.off": "aus",
"ver.clean.hint": "Experimentell. Die Szene selbst bleibt, wie sie ist; nur diese Fassung wird bereinigt – am besten neben einer ohne vergleichen.",
"ver.clean.ceiling": "Decke", "ver.clean.ceiling.title": "Eine weiße Decke gibt der Rekonstruktion keinen Halt: Sie bleibt dünn, darunter hängt ein dunkler Schleier. Nimmt den Schleier heraus (je stärker, desto tiefer; Dunkles bis 50 cm an einer Wand bleibt) und was über der Decke liegt, und setzt eine geschlossene Decke in ihrer eigenen Farbe ein.",
"ver.clean.floor": "Boden", "ver.clean.floor.title": "Füllt Löcher im Boden in der Farbe des Bodens drumherum (je stärker, desto mehr gilt als Loch) und nimmt heraus, was unter dem Boden liegt.",
"ver.clean.haze": "Dunst", "ver.clean.haze.title": "Nimmt große, fast durchsichtige Wolken heraus, die im Raum schweben. Lange dünne Dinge (Gitter, Kabel, Drähte) sind kein Dunst und bleiben. Je stärker, desto kleinere und weniger durchsichtige Wolken gehen mit.",
"ver.clean.outside": "Rand", "ver.clean.outside.title": "Nimmt heraus, was deutlich außerhalb des Raums liegt: hinter den Wänden, über der Decke, unter dem Boden – von 1 m Abstand bis 30 cm bei voller Stärke. Auch der Blick durchs Fenster.",
"err.mixed_scale": "Die Szenen haben verschiedene Maßstäbe – einen Maßstab für die Gruppe setzen", "err.plan_failed": "Der Grundriss konnte nicht gezeichnet werden", "sc.replacing": "Wird neu gerechnet – bleibt, bis die neue fertig ist", "err.plan_busy": "Gerade werden andere Grundrisse gezeichnet, gleich noch einmal versuchen",
"plan.measure": "📏 Messen", "plan.measure.title": "Abstände im Grundriss messen",
"plan.measure.hint": "Klicken setzt Punkte · Rechtsklick nimmt den letzten zurück · waagerechte Abstände, etwa ±5 cm genau",
"plan.measure.undo": "Rückgängig", "plan.measure.reset": "Zurücksetzen",
"plan.measure.start": "Ersten Punkt anklicken", "plan.measure.noscale": "Maßstab fehlt – Splat-Werkstatt neu starten", "plan.measure.total": "Gesamt {d}",
"err.name_taken": "Es gibt schon eine Aufnahme mit diesem Namen", "err.recording_busy": "Die Aufnahme wird gerade umgewandelt oder geprüft",
"err.scene_busy": "Die Szene wird gerade berechnet oder übertragen",
"err.recording_unfinished": "„{name}“ aus dieser Aufnahme ist noch nicht fertig – erst umbenennen, wenn die Szene fertig ist (sonst lässt sie sich nicht fortsetzen)",
"err.not_local": "Die Aufnahme liegt nicht auf dem PC", "err.download_busy": "Es läuft schon ein Download",
"err.job_busy": "Es läuft schon eine Umwandlung", "err.unknown_recording": "Unbekannte Aufnahme",
"err.bad_name": "Bitte einen Namen angeben (Buchstaben/Ziffern)", "err.bad_quality": "Unbekannte Qualitätsstufe",
+1040 -40
View File
File diff suppressed because it is too large. Load diff