mirror of
https://github.com/thunderheronight/splat-workshop.git
synced 2026-10-18 11:01:32 +02:00
Compare commits
No files matched your search
@@ -0,0 +1,14 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
- name: Run the tests
|
||||
run: uv run --no-project --python 3.12 --with pytest --with numpy --with scipy --with pillow pytest -v
|
||||
@@ -3,3 +3,4 @@ __pycache__/
|
||||
.venv/
|
||||
.splat-workshop/
|
||||
*.part
|
||||
.pytest_cache/
|
||||
@@ -0,0 +1,71 @@
|
||||
# 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.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.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
|
||||
+47
-3
@@ -1,17 +1,39 @@
|
||||
# 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.**
|
||||
|
||||
  [](https://github.com/thunderheronight/splat-workshop/releases) [](https://github.com/thunderheronight/splat-workshop/actions/workflows/tests.yml)
|
||||
|
||||
> **Kein Root, kein Jailbreak.** Auf der Frame nutzt die Splat-Werkstatt nur den offiziellen *Entwicklermodus* aus den Steam-Einstellungen und SSH als normaler Benutzer – auf der Brille wird nichts installiert, ihr System bleibt unangetastet. Auch am PC läuft sie als dein Benutzer (nur die Voraussetzungen wie ffmpeg kommen aus der Paketverwaltung).
|
||||
|
||||
[English](README.md)
|
||||
|
||||

|
||||
|
||||
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.
|
||||
5. **Die Wohnung zusammensetzen**: Räume gruppieren und zu einem Rundgang machen, durch den du gehst – voll für den PC, kleiner für die Frame.
|
||||
|
||||
## 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 +42,14 @@ 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.
|
||||
- **Gruppen und Rundgänge**: Szenen gruppieren (z. B. alle Räume der Wohnung) und zu einer Szene zusammensetzen – eine volle Fassung für Gaussian XR am PC und eine kleinere für die Frame. Aufnahmen aus einem Durchgang teilen das Tracking-Koordinatensystem der Frame; die Werkstatt richtet die Räume aneinander aus (Korrekturen A–D zur Wahl), legt die Grenzen zwischen den Aufnahmen in die Türen und zeigt, wie gut die Räume aneinanderpassen. Vor dem Start schätzt sie den Speicherbedarf.
|
||||
<br><img src="docs/combine.png" width="360" alt="Dialog „Zusammensetzen“: Korrekturen A bis D, Grenzen zwischen den Aufnahmen, Größe der Frame-Fassung, volle Fassung, Speicherschätzung">
|
||||
<br><sub>Beispieldaten (englische Oberfläche) – Raumnamen und Vorschaubilder sind Platzhalter.</sub>
|
||||
- **Grundrisse** *(experimentell)* einzelner Szenen oder einer ganzen Gruppe – Aufnahmen aus einem Durchgang fügen sich von selbst zum Grundriss der ganzen Wohnung. Zwei Stile: *Hübsch* (Draufsicht in Farbe, Wände, gelaufener Weg) und *Klassisch* (leuchtende Punktdichte), dazu ein einfaches Messwerkzeug (Punkte anklicken, Abstände ablesen – etwa ±5 cm genau).
|
||||
- **Fertige Szenen**: später auf die Frame übertragen, erneut übertragen, umbenennen (Ordner und Namensschild ziehen mit), löschen (in den Papierkorb), Ordner oder Spirula Studio öffnen.
|
||||
- **Abgebrochene Läufe** werden mit ihrem Stand angezeigt: fortsetzen, neu starten oder löschen.
|
||||
- **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,6 +62,16 @@ 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.
|
||||
@@ -40,10 +79,15 @@ Nach dem Übertragen **Gaussian XR auf der Frame neu starten**, damit die Szene
|
||||
## 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.
|
||||
- **Räume**: 30–90 s pro Raum, langsam gehen und langsam drehen, Dinge von mehreren Seiten zeigen.
|
||||
- **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.
|
||||
- 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).
|
||||
@@ -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.**
|
||||
|
||||
  [](https://github.com/thunderheronight/splat-workshop/releases) [](https://github.com/thunderheronight/splat-workshop/actions/workflows/tests.yml)
|
||||
|
||||
> **No root, no jailbreak.** On the Frame, Splat Workshop only uses the official *Developer Mode* from the Steam settings and SSH as the normal user – nothing is installed on the headset and its system stays untouched. On the PC it runs as your user, too (only the prerequisites like ffmpeg come from your package manager).
|
||||
|
||||
[Deutsch](README.de.md)
|
||||
|
||||
@@ -8,12 +12,30 @@
|
||||
|
||||
<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.
|
||||
5. **Put your home together**: group the rooms and turn them into one tour you walk through – full size for the PC, smaller for the Frame.
|
||||
|
||||
## 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 +44,29 @@ 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.
|
||||
- **Groups and tours**: group scenes (e.g. all rooms of your home) and put them together into one scene – a full version for Gaussian XR on the PC and a smaller one for the Frame. Recordings from one session share the Frame's tracking frame; Splat Workshop fits the rooms to each other (corrections A–D to choose), lets the borders between recordings run through doors and shows how well the rooms meet. Before starting it estimates the memory needed.
|
||||
<br><img src="docs/combine.png" width="360" alt="Dialog “Put together”: corrections A to D, borders between the recordings, size of the Frame version, full version, memory estimate">
|
||||
<br><sub>Example data – room names and preview images are placeholders.</sub>
|
||||
- **Floor plans** *(experimental)* of single scenes or a whole group – recordings from one session line up by themselves into a plan of your whole home. Two styles: *Pretty* (colour top view, walls, the path you walked) and *Classic* (glowing point density), plus a simple measuring tool (click points, get distances – about ±5 cm).
|
||||
<br><img src="docs/floorplan.png" width="640" alt="Combined floor plan of an example home in the classic style, with the measuring tool">
|
||||
<br><sub>Example home – synthetic data, drawn with the same renderer. Combining rooms relies on the Frame keeping its tracking frame between recordings; how well that holds for a whole home is still being tested.</sub>
|
||||
- **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 +94,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
|
||||
|
||||
@@ -76,6 +112,7 @@ Start **Splat Workshop** from the menu (or `./splat-workshop`). The tray icon ap
|
||||
| `./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/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`) |
|
||||
|
||||
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.
|
||||
|
||||
@@ -85,9 +122,10 @@ 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.
|
||||
- **Rooms**: 30–90 s each, walk slowly, turn slowly, look at things from several sides.
|
||||
- **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.
|
||||
- Moving people or pets become ghostly smears.
|
||||
|
||||
@@ -123,13 +161,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: [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`), 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
@@ -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"
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
Ohne globale Installation: `npm install --prefix ~/arcturus/node @playcanvas/splat-transform` und in der Config `splat_transform = "~/arcturus/node/bin/splat-transform"` eintragen.
|
||||
|
||||
## 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.) |
|
||||
| `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. |
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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 you can't or don't want to install globally: `npm install --prefix ~/arcturus/node @playcanvas/splat-transform` and set `splat_transform = "~/arcturus/node/bin/splat-transform"` in the config.
|
||||
|
||||
## 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.) |
|
||||
| `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. |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 267 KiB |
+2
-2
@@ -46,8 +46,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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
@@ -0,0 +1,421 @@
|
||||
# /// 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]
|
||||
|
||||
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 another recording's round (walking in/out) does not
|
||||
count, so the borders run through doors (default)
|
||||
nearest the recording whose camera was closest
|
||||
3. Every room is transformed, cut to its own part and appended – one room in memory at a time.
|
||||
|
||||
Writes <out>/spirula/scene.ply (all splats), <out>/scene.xrSceneTransform.json (start view of the
|
||||
room in the middle) and <out>/combine.json (settings and how well the overlaps match).
|
||||
Lines starting with "@@" are progress markers for the web interface.
|
||||
"""
|
||||
import argparse
|
||||
import itertools
|
||||
import json
|
||||
import shutil
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial import cKDTree
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
VOXEL, RADIUS, K = 0.10, 1.5, 64 # ownership: cube size, camera reach, cameras looked at
|
||||
BOTH = 1.5 # an overlap: places within this distance of both recordings' cameras
|
||||
|
||||
|
||||
def progress(pct):
|
||||
print(f"@@ pct {int(pct)}", flush=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reading
|
||||
|
||||
def read_cameras(path: Path):
|
||||
"""Image names, camera centres, viewing directions (COLMAP: the camera looks along +z)."""
|
||||
names, centres, fwd = [], [], []
|
||||
with open(path, "rb") as f:
|
||||
n, = struct.unpack("<Q", f.read(8))
|
||||
for _ in range(n):
|
||||
_, qw, qx, qy, qz, tx, ty, tz, _ = struct.unpack("<i7di", f.read(64))
|
||||
name = b""
|
||||
while (ch := f.read(1)) != b"\0":
|
||||
name += ch
|
||||
k, = struct.unpack("<Q", f.read(8))
|
||||
f.seek(24 * k, 1)
|
||||
R = Rotation.from_quat([qx, qy, qz, qw]).as_matrix()
|
||||
names.append(name.decode().replace("\\", "/"))
|
||||
centres.append(-R.T @ np.array([tx, ty, tz]))
|
||||
fwd.append(R.T[:, 2])
|
||||
return names, np.array(centres), np.array(fwd)
|
||||
|
||||
|
||||
def read_points(path: Path):
|
||||
"""Sparse points with a small reprojection error."""
|
||||
with open(path, "rb") as f:
|
||||
n, = struct.unpack("<Q", f.read(8))
|
||||
xyz, err = np.empty((n, 3)), np.empty(n)
|
||||
for i in range(n):
|
||||
_, x, y, z, _, _, _, e, k = struct.unpack("<Q3d3BdQ", f.read(51))
|
||||
f.seek(8 * k, 1)
|
||||
xyz[i], err[i] = (x, y, z), e
|
||||
return xyz[err < 2.0]
|
||||
|
||||
|
||||
def read_ply(path: Path):
|
||||
with open(path, "rb") as f:
|
||||
header = b""
|
||||
while not header.endswith(b"end_header\n"):
|
||||
line = f.readline()
|
||||
if not line:
|
||||
sys.exit(f"Not a PLY file: {path}")
|
||||
header += line
|
||||
props = [l.split()[-1].decode() for l in header.splitlines() if l.startswith(b"property")]
|
||||
n = int(next(l for l in header.splitlines() if l.startswith(b"element vertex")).split()[-1])
|
||||
data = np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
|
||||
return header, data
|
||||
|
||||
|
||||
def image_time(name: str) -> float:
|
||||
"""cam0/0123-45.678901.jpg -> 45.678901 (seconds into the recording)."""
|
||||
return float(name.rsplit("/", 1)[-1].split("-", 1)[1].removesuffix(".jpg"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- transforms
|
||||
|
||||
class Pose:
|
||||
"""x -> s R x + t"""
|
||||
def __init__(self, s=1.0, R=None, t=None):
|
||||
self.s, self.R, self.t = s, np.eye(3) if R is None else R, np.zeros(3) if t is None else t
|
||||
|
||||
def __call__(self, p):
|
||||
return (self.s * (self.R @ p.T)).T + self.t
|
||||
|
||||
def then(self, R, t, centre):
|
||||
"""Followed by a rigid motion about `centre`."""
|
||||
return Pose(self.s, R @ self.R, R @ (self.t - centre) + centre + t)
|
||||
|
||||
|
||||
def umeyama(A, B):
|
||||
"""Similarity that maps the points A onto B (least squares)."""
|
||||
ma, mb = A.mean(0), B.mean(0)
|
||||
a, b = A - ma, B - mb
|
||||
U, S, Vt = np.linalg.svd(b.T @ a / len(A))
|
||||
D = np.eye(3)
|
||||
if np.linalg.det(U @ Vt) < 0:
|
||||
D[2, 2] = -1
|
||||
R = U @ D @ Vt
|
||||
s = np.trace(np.diag(S) @ D) / a.var(0).sum()
|
||||
return Pose(s, R, mb - s * R @ ma)
|
||||
|
||||
|
||||
def quat_mul(a, b):
|
||||
"""a (w, x, y, z) times every row of b."""
|
||||
w1, x1, y1, z1 = a
|
||||
w2, x2, y2, z2 = b.T
|
||||
return np.stack([w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2, w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,
|
||||
w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2, w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2], axis=1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- overlaps
|
||||
|
||||
def overlap_zones(rooms):
|
||||
"""Sparse points of each pair where both recordings were (doors, passages). The two sides of a
|
||||
wall are never both within reach of both, so they are not mistaken for the same surface."""
|
||||
zones = {}
|
||||
for a, b in itertools.combinations(rooms, 2):
|
||||
ta, tb = cKDTree(rooms[a]["cams"]), cKDTree(rooms[b]["cams"])
|
||||
both = lambda p: p[(ta.query(p)[0] < BOTH) & (tb.query(p)[0] < BOTH)]
|
||||
za, zb = both(rooms[a]["pts"]), both(rooms[b]["pts"])
|
||||
if len(za) >= 1000 and len(zb) >= 1000:
|
||||
zones[(a, b)] = (za, zb)
|
||||
return zones
|
||||
|
||||
|
||||
def offset(pa, pb):
|
||||
"""Shift that brings the overlap pa onto pb (robust, a few ICP steps) and how many points matched."""
|
||||
tree = cKDTree(pb)
|
||||
d, i = tree.query(pa, distance_upper_bound=0.08)
|
||||
ok = np.isfinite(d)
|
||||
if ok.sum() < 100:
|
||||
return None, 0
|
||||
P, Q, tot = pa[ok], pb[i[ok]], np.zeros(3)
|
||||
for _ in range(15):
|
||||
delta = np.median(Q - P, axis=0)
|
||||
P, tot = P + delta, tot + delta
|
||||
d2, i2 = tree.query(P, distance_upper_bound=0.05)
|
||||
ok2 = np.isfinite(d2)
|
||||
if ok2.sum() < 50:
|
||||
break
|
||||
P, Q = P[ok2], pb[i2[ok2]]
|
||||
return tot, int(ok.sum())
|
||||
|
||||
|
||||
def seams(rooms, zones, poses):
|
||||
out = {}
|
||||
for (a, b), (za, zb) in zones.items():
|
||||
tot, n = offset(poses[a](za), poses[b](zb))
|
||||
if tot is not None:
|
||||
out[f"{a}|{b}"] = {"cm": round(float(np.linalg.norm(tot)) * 100, 1), "points": n}
|
||||
return out
|
||||
|
||||
|
||||
def joint_shift(rooms, zones, poses, fixed):
|
||||
"""Shift every room (except `fixed`) so all overlaps match best together (least squares)."""
|
||||
free = [l for l in rooms if l != fixed]
|
||||
rows, rhs = [], []
|
||||
for (a, b), (za, zb) in zones.items():
|
||||
tot, n = offset(poses[a](za), poses[b](zb))
|
||||
if tot is None:
|
||||
continue
|
||||
row = np.zeros(len(free))
|
||||
if a in free:
|
||||
row[free.index(a)] += 1 # (x_a + t_a) meets (x_b + t_b) when t_a - t_b = tot
|
||||
if b in free:
|
||||
row[free.index(b)] -= 1
|
||||
rows.append(row * np.sqrt(n))
|
||||
rhs.append(tot * np.sqrt(n))
|
||||
if not rows:
|
||||
return poses
|
||||
sol, *_ = np.linalg.lstsq(np.array(rows), np.array(rhs), rcond=None)
|
||||
return {l: poses[l] if l == fixed else poses[l].then(np.eye(3), sol[free.index(l)], np.zeros(3)) for l in rooms}
|
||||
|
||||
|
||||
def joint_rigid(rooms, zones, poses, fixed, centre):
|
||||
"""Small rotation + shift per room (except `fixed`) over the point pairs of all overlaps."""
|
||||
free = [l for l in rooms if l != fixed]
|
||||
rng = np.random.default_rng(1)
|
||||
sub = {k: (za[rng.choice(len(za), min(len(za), 20000), replace=False)], zb) for k, (za, zb) in zones.items()}
|
||||
for cut in (0.08, 0.06, 0.05, 0.04, 0.03, 0.03):
|
||||
rows, rhs = [], []
|
||||
for (a, b), (za, zb) in sub.items():
|
||||
pa, pbz = poses[a](za), poses[b](zb)
|
||||
d, i = cKDTree(pbz).query(pa, distance_upper_bound=cut)
|
||||
ok = np.isfinite(d)
|
||||
if ok.sum() < 200:
|
||||
continue
|
||||
P, Q = pa[ok] - centre, pbz[i[ok]] - centre
|
||||
n, w = len(P), 1 / np.sqrt(ok.sum()) # every overlap counts about equally
|
||||
|
||||
def cross(X): # rows of -[X]x: (w x X) = cross(X) @ w
|
||||
z = np.zeros(n)
|
||||
return -np.stack([np.stack([z, -X[:, 2], X[:, 1]], 1), np.stack([X[:, 2], z, -X[:, 0]], 1),
|
||||
np.stack([-X[:, 1], X[:, 0], z], 1)], 1)
|
||||
A = np.zeros((n, 3, 6 * len(free)))
|
||||
if a in free:
|
||||
j = free.index(a) * 6
|
||||
A[:, :, j:j + 3], A[:, :, j + 3:j + 6] = cross(P), np.eye(3)
|
||||
if b in free:
|
||||
j = free.index(b) * 6
|
||||
A[:, :, j:j + 3], A[:, :, j + 3:j + 6] = -cross(Q), -np.eye(3)
|
||||
rows.append(A.reshape(-1, 6 * len(free)) * w)
|
||||
rhs.append(-(P - Q).reshape(-1) * w)
|
||||
if not rows:
|
||||
break
|
||||
x, *_ = np.linalg.lstsq(np.vstack(rows), np.concatenate(rhs), rcond=None)
|
||||
for l in free:
|
||||
j = free.index(l) * 6
|
||||
poses[l] = poses[l].then(Rotation.from_rotvec(x[j:j + 3]).as_matrix(), x[j + 3:j + 6], centre)
|
||||
return poses
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- ownership
|
||||
|
||||
def owners(rooms, poses, partition):
|
||||
"""Function: points -> index of the owning room."""
|
||||
labels = list(rooms)
|
||||
cams = [poses[l](rooms[l]["cams"]) for l in labels]
|
||||
fwd = [(poses[l].R @ rooms[l]["fwd"].T).T for l in labels]
|
||||
owner = np.concatenate([np.full(len(c), i) for i, c in enumerate(cams)])
|
||||
if partition == "nearest":
|
||||
tree = cKDTree(np.vstack(cams))
|
||||
return lambda p: owner[tree.query(p, workers=-1)[1]], {}
|
||||
|
||||
# walking in and out: the first and last stretch of a recording along another recording's path ...
|
||||
n = len(cams)
|
||||
|
||||
def edges(i, others):
|
||||
inside = cKDTree(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, np.vstack([cams[j] for j in range(n) if j != i])) 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 = np.vstack([cams[j][~cand[j]] for j in range(n) if j != i])
|
||||
transit.append(cand[i] & (cKDTree(core).query(cams[i])[0] < 0.4))
|
||||
transit = np.concatenate(transit)
|
||||
C, F, O = np.vstack(cams)[~transit], np.vstack(fwd)[~transit], owner[~transit]
|
||||
tree = cKDTree(C)
|
||||
|
||||
def own(points):
|
||||
keys = np.floor(points / VOXEL).astype(np.int64)
|
||||
uniq, inv = np.unique(keys, axis=0, return_inverse=True)
|
||||
centre = (uniq + 0.5) * VOXEL
|
||||
lab = np.empty(len(uniq), dtype=np.int64)
|
||||
for s in range(0, len(uniq), 200_000):
|
||||
c = centre[s:s + 200_000]
|
||||
d, j = tree.query(c, k=K, distance_upper_bound=RADIUS, workers=-1)
|
||||
ok = np.isfinite(d)
|
||||
j = np.where(ok, j, 0)
|
||||
to = c[:, None, :] - C[j]
|
||||
cos = np.einsum("nkd,nkd->nk", to, F[j]) / np.maximum(np.linalg.norm(to, axis=2), 1e-6)
|
||||
w = np.where(ok & (cos > 0.3), (RADIUS - np.where(ok, d, RADIUS)) * cos, 0.0)
|
||||
score = np.stack([(w * (O[j] == r)).sum(1) for r in range(n)], axis=1)
|
||||
best = score.argmax(1)
|
||||
none = score.max(1) == 0
|
||||
if none.any():
|
||||
best[none] = O[tree.query(c[none], workers=-1)[1]]
|
||||
lab[s:s + 200_000] = best
|
||||
return lab[inv.ravel()]
|
||||
return own, {labels[i]: int(t.sum()) for i, t in enumerate(np.split(transit, np.cumsum([len(c) for c in cams])[:-1]))}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- main
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("out")
|
||||
ap.add_argument("runs", nargs="+", help="run folder=label")
|
||||
ap.add_argument("--align", choices=("frame", "images", "shift", "rigid"), default="images")
|
||||
ap.add_argument("--partition", choices=("view", "nearest"), default="view")
|
||||
args = ap.parse_args()
|
||||
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, "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"]), "cameras": len(r["cams"]), "tracking_cm": round(rms(Pose(), r) * 100, 1),
|
||||
"fitted_cm": round(rms(fit[l], r) * 100, 1)} for l, r in rooms.items()}
|
||||
# From here on everything is in the fitted frame; `poses` are applied on top of it.
|
||||
for l, r in rooms.items():
|
||||
r["cams"], r["pts"] = fit[l](r["cams"]), fit[l](r["pts"])
|
||||
r["fwd"] = (fit[l].R @ r["fwd"].T).T
|
||||
poses = {l: Pose() for l in rooms}
|
||||
if args.align == "frame": # undo the fit: as the pipeline delivered it
|
||||
poses = {l: Pose(1 / fit[l].s, fit[l].R.T, -fit[l].R.T @ fit[l].t / fit[l].s) for l in rooms}
|
||||
zones = overlap_zones(rooms)
|
||||
progress(55)
|
||||
|
||||
# one recording session: every room has to overlap with the others (one connected group)
|
||||
links = {l: set() for l in rooms}
|
||||
for a, b in zones:
|
||||
links[a].add(b)
|
||||
links[b].add(a)
|
||||
seen, todo = set(), [next(iter(rooms))]
|
||||
while todo:
|
||||
l = todo.pop()
|
||||
if l not in seen:
|
||||
seen.add(l)
|
||||
todo += list(links[l])
|
||||
if len(rooms) > 1 and seen != set(rooms):
|
||||
lone = ", ".join(sorted(set(rooms) - seen))
|
||||
print(f"@@ error not_connected {lone}", flush=True)
|
||||
sys.exit(f"These scenes do not touch the others: {lone}. Only rooms of one recording session "
|
||||
"(without restarting the Frame in between) share one frame and can be put together.")
|
||||
hub = max(rooms, key=lambda l: (len(links[l]), sum(len(zones[k][0]) for k in zones if l in k)))
|
||||
before = seams(rooms, zones, poses)
|
||||
centre = np.vstack([r["cams"] for r in rooms.values()]).mean(0)
|
||||
if args.align == "shift":
|
||||
poses = joint_shift(rooms, zones, poses, hub)
|
||||
elif args.align == "rigid":
|
||||
poses = joint_rigid(rooms, zones, poses, hub, centre)
|
||||
after = seams(rooms, zones, poses)
|
||||
# from the room's own frame: first the fit, then the poses
|
||||
final = {l: Pose(poses[l].s * fit[l].s, poses[l].R @ fit[l].R, poses[l](fit[l].t)) for l in rooms}
|
||||
progress(100)
|
||||
|
||||
print("@@ cphase 1", flush=True)
|
||||
own, transit = owners(rooms, poses, args.partition)
|
||||
body = out / "spirula" / "scene.body.tmp"
|
||||
total, kept, header = 0, {}, None
|
||||
with open(body, "wb") as fb:
|
||||
for i, (l, r) in enumerate(rooms.items()):
|
||||
header, v = read_ply(r["run"] / "spirula" / "scene.ply")
|
||||
xyz = final[l](np.stack([v["x"], v["y"], v["z"]], axis=1).astype(np.float64))
|
||||
keep = own(xyz) == i
|
||||
v, xyz = v[keep], xyz[keep]
|
||||
v["x"], v["y"], v["z"] = xyz.T.astype(np.float32)
|
||||
q = Rotation.from_matrix(final[l].R).as_quat() # (x, y, z, w)
|
||||
rot = quat_mul(np.array([q[3], q[0], q[1], q[2]]),
|
||||
np.stack([v["rot_0"], v["rot_1"], v["rot_2"], v["rot_3"]], axis=1).astype(np.float64))
|
||||
v["rot_0"], v["rot_1"], v["rot_2"], v["rot_3"] = rot.T.astype(np.float32)
|
||||
for k in ("scale_0", "scale_1", "scale_2"):
|
||||
v[k] += np.float32(np.log(final[l].s))
|
||||
v.tofile(fb)
|
||||
total += len(v)
|
||||
kept[l] = {"splats": int(keep.size), "kept": int(keep.sum())}
|
||||
print(f"{l}: {keep.sum():,} of {keep.size:,} splats kept", flush=True)
|
||||
del v, xyz, rot, keep
|
||||
progress(100 * (i + 1) / len(rooms))
|
||||
lines = [b"element vertex %d\n" % total if l.startswith(b"element vertex") else l
|
||||
for l in header.splitlines(keepends=True)]
|
||||
with open(out / "spirula" / "scene.ply", "wb") as fo:
|
||||
fo.write(b"".join(lines))
|
||||
with open(body, "rb") as fb:
|
||||
shutil.copyfileobj(fb, fo, 16 << 20)
|
||||
body.unlink()
|
||||
|
||||
# start view: the one of the room in the middle
|
||||
shutil.copy(rooms[hub]["run"] / "scene.xrSceneTransform.json", out / "scene.xrSceneTransform.json")
|
||||
for l in rooms:
|
||||
room_info[l].update(kept[l], walk_in_out_cameras=transit.get(l, 0))
|
||||
result = {"align": args.align, "partition": args.partition, "hub": hub, "splats": total,
|
||||
"rooms": room_info, "seams_before": before, "seams": after,
|
||||
"seconds": round(time.time() - started)}
|
||||
(out / "combine.json").write_text(json.dumps(result, ensure_ascii=False, indent=1))
|
||||
worst = max(after.values(), key=lambda s: s["cm"]) if after else None
|
||||
print(f"@@ total {total}", flush=True)
|
||||
print(f"{total:,} splats; overlaps match within "
|
||||
+ (f"{np.median([s['cm'] for s in after.values()]):.1f} cm (median), worst {worst['cm']} cm" if worst else "–"),
|
||||
flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/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)
|
||||
# FRAME_SPLATS=3500000 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
|
||||
# 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:-3500000}
|
||||
|
||||
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}"
|
||||
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
|
||||
uv run -q "$HERE/floorplan.py" "$tmp/plan.png" "$@" ${PLAN_COMMA:+--comma} >/dev/null
|
||||
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"
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
# /// 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
|
||||
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):
|
||||
"""Yaw that makes the walls axis-aligned: maximise the peakiness of the x/z histograms."""
|
||||
best, best_score = 0.0, -1
|
||||
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()) / 0.03)))
|
||||
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("--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)
|
||||
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) & (xyz[:, 1] < floor + 0.15)
|
||||
& np.all(xyz[:, [0, 2]] >= lo - 0.3, axis=1) & np.all(xyz[:, [0, 2]] <= hi + 0.3, axis=1))
|
||||
up = floor - xyz[:, 1] # height above the floor in metres
|
||||
walls = inside & (up > 0.9) & (up < 1.9)
|
||||
scenes.append({"label": label or run.name, "xyz": xyz[inside], "rgb": rgb[inside], "up": up[inside],
|
||||
"walls": xyz[walls][:, [0, 2]], "cams": load_cameras(run), "floor": floor})
|
||||
|
||||
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)
|
||||
for s in scenes:
|
||||
s["p"] = rotate(s["xyz"][:, [0, 2]], angle)
|
||||
s["w"] = rotate(s["walls"], angle)
|
||||
s["c"] = rotate(s["cams"][:, [0, 2]], angle) if s["cams"] is not None else None
|
||||
s["box"] = room_box(s["w"])
|
||||
if s["box"] is not None: # keep only this room (plus a margin)
|
||||
b0, b1 = s["box"][0] - 0.35, s["box"][1] + 0.35
|
||||
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
|
||||
hi = np.max([b[1] for b in boxes], axis=0) + 0.8
|
||||
else:
|
||||
allp = np.vstack([s["p"] for s in scenes])
|
||||
lo, hi = np.percentile(allp, 1, axis=0) - 0.5, np.percentile(allp, 99, axis=0) + 0.5
|
||||
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)]
|
||||
if args.style == "pretty" and s["c"] is not None and len(s["c"]) > 1:
|
||||
x, z = px(s["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:.1f} × {bz1 - bz0:.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 (1 m) and title
|
||||
d.line((W - 60 - ppm, H - 40, W - 60, H - 40), fill=(230, 230, 230), width=3)
|
||||
for xx in (W - 60 - ppm, W - 60):
|
||||
d.line((xx, H - 47, xx, H - 33), fill=(230, 230, 230), width=3)
|
||||
d.text((W - 60 - ppm / 2 - 12, H - 72), "1 m", 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), 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, "pixels_per_metre": round(ppm, 1)}))
|
||||
|
||||
|
||||
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()
|
||||
+23
-1
@@ -5,6 +5,7 @@
|
||||
"""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>
|
||||
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 +83,33 @@ 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) == 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}\"")
|
||||
|
||||
|
||||
+54
-2
@@ -13,13 +13,17 @@
|
||||
# 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
|
||||
# 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,7 +61,51 @@ fi
|
||||
out=$RUNS_DIR/$slug
|
||||
log=$RUNS_DIR/$slug.log
|
||||
mkdir -p "$RUNS_DIR"
|
||||
if [ "${FRESH:-0}" = 1 ]; then rm -rf "$out" "$log"; 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() {
|
||||
@@ -84,6 +132,8 @@ 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; }
|
||||
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 +143,5 @@ if [ "${NO_UPLOAD:-0}" = 1 ] || [ -z "$FRAME_HOST" ]; then
|
||||
exit 0
|
||||
fi
|
||||
LOG="$log" "$HERE/to-frame.sh" "$out" "$name"
|
||||
exit
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
# STEM=name file stem (default: folder name in lower case)
|
||||
# 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")
|
||||
@@ -45,8 +48,16 @@ folder=${TARGET:-$(ssh -o ConnectTimeout=8 "$FRAME_HOST" "
|
||||
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/"
|
||||
# Files of this scene under an earlier name (renamed in Splat Workshop) go first, so the folder holds one scene
|
||||
ssh "$FRAME_HOST" "for d in $FRAME_SCENES/$folder $FRAME_BACKUP/$folder; do
|
||||
for f in \$d/*.sog \$d/*.xrSceneTransform.json \$d/*.gallery.png \$d/*.sceneinfo.png; do
|
||||
case \${f##*/} in $stem.*) ;; *) rm -f -- \"\$f\" ;; esac
|
||||
done
|
||||
done"
|
||||
ssh "$FRAME_HOST" "cp $FRAME_BACKUP/$folder/$stem.* $FRAME_SCENES/$folder/ && 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
|
||||
}
|
||||
@@ -25,6 +25,7 @@ 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"},
|
||||
@@ -48,6 +49,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")
|
||||
|
||||
@@ -8,6 +8,11 @@ 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)",
|
||||
"notify.failed.title": "Conversion failed",
|
||||
"notify.failed": "“{name}” – details in Splat Workshop",
|
||||
"notify.upload.title": "Copied to the Frame",
|
||||
@@ -52,6 +57,11 @@ 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)",
|
||||
"notify.failed.title": "Umwandlung fehlgeschlagen",
|
||||
"notify.failed": "„{name}“ – Details in der Splat-Werkstatt",
|
||||
"notify.upload.title": "Auf die Frame übertragen",
|
||||
|
||||
+828
-47
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,286 @@
|
||||
"""Test setup: a temporary home for Splat Workshop with fake tools, and the server on a free port.
|
||||
|
||||
The real Arcturus pipeline, Spirula, splat-transform and the desktop trash are replaced by small fakes
|
||||
(written below), so the tests run anywhere – no GPU, no recordings, no Steam Frame. The server reads
|
||||
its configuration when it is imported, so everything is set up before that, once per test session.
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path[:0] = [str(ROOT), str(Path(__file__).parent)]
|
||||
|
||||
BASE = Path(tempfile.mkdtemp(prefix="splat-workshop-tests-"))
|
||||
RUNS, REC, STATE, BIN, TRASH, PIPE, FRAME_HOME = (BASE / n for n in (
|
||||
"runs", "recordings", "state", "bin", "trash", "pipeline", "frame"))
|
||||
for d in (RUNS, REC, STATE, BIN, TRASH, PIPE, FRAME_HOME):
|
||||
d.mkdir()
|
||||
TOOL_LOG = BASE / "tools.log" # what the fake tools were called with, one JSON line each
|
||||
|
||||
|
||||
def script(path: Path, text: str):
|
||||
path.write_text(text)
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
# Fake pipeline: behaves like av1_to_3dgs.py as far as Splat Workshop can see (progress lines, result files).
|
||||
# FAKE_PIPELINE=ok (default) | fail | slow (waits for 60 s, to be cancelled)
|
||||
script(PIPE / "av1_to_3dgs.py", r'''
|
||||
import base64, json, os, sys, time
|
||||
from pathlib import Path
|
||||
out = Path(sys.argv[2])
|
||||
mode = os.environ.get("FAKE_PIPELINE", "ok")
|
||||
# like the real pipeline: an unfinished training blocks --resume ("Incomplete training retained")
|
||||
run_json = out / "spirula" / "run.json"
|
||||
if "--resume" in sys.argv and run_json.exists() and json.loads(run_json.read_text()).get("status") != "complete":
|
||||
sys.exit("error: Incomplete training retained; use a new output directory to restart")
|
||||
# like the real one: an existing output folder is only continued with --resume, and only once the camera
|
||||
# positions are complete ("Incomplete native SfM retained" / "already exists without a matching manifest")
|
||||
dataset = out / "dataset" / "spirula-dataset.json"
|
||||
if out.exists() and any(out.iterdir()):
|
||||
if "--resume" not in sys.argv:
|
||||
sys.exit("error: Spirula SfM output already exists without a matching resume manifest")
|
||||
if not dataset.exists() or json.loads(dataset.read_text()).get("status") != "complete":
|
||||
sys.exit("error: Incomplete native SfM retained for diagnosis; use a fresh output directory")
|
||||
(out / "dataset").mkdir(parents=True, exist_ok=True)
|
||||
print("Extracting 20 images from 2 video streams", flush=True)
|
||||
(out / "dataset" / "spirula-dataset.json").write_text(json.dumps({"status": "complete"}))
|
||||
print("Running native Spirula SfM", flush=True)
|
||||
print("Aligned 20 cameras using orientation anchor cam0/0000.jpg: RMS 0.0110 m, median 0.0090 m.", flush=True)
|
||||
if mode == "fail":
|
||||
sys.exit("fake pipeline: failed on purpose")
|
||||
for step in (1000, 2000, 3000):
|
||||
print(f"step {step:6d}/3000 ({step // 30:3d}%) splats 1000 [elapsed 00:00:0{step // 1000}.000 | ETA 00:00:01.000] rgb_loss=0.1 ssim=0.8 psnr=28.5", flush=True)
|
||||
if mode == "slow":
|
||||
time.sleep(20)
|
||||
(out / "spirula").mkdir(exist_ok=True)
|
||||
(out / "spirula" / "scene.ply").write_bytes(b"ply\n")
|
||||
run_json.write_text(json.dumps({"status": "complete"}))
|
||||
print("spirula: 1,000 Gaussians in " + str(out / "spirula" / "scene.ply"), flush=True)
|
||||
(out / "scene.sog").write_bytes(b"fake sog")
|
||||
(out / "scene.xrSceneTransform.json").write_text(json.dumps({"transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}))
|
||||
png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
|
||||
(out / (out.name.lower() + ".gallery.png")).write_bytes(png)
|
||||
print("Gaussian XR scene transform: " + str(out / "scene.xrSceneTransform.json"), flush=True)
|
||||
''')
|
||||
script(BIN / "spirula", "#!/bin/sh\nexit 0\n")
|
||||
# splat-transform: copies the input (a .ply stays a .ply), writes a small file for .sog
|
||||
script(BIN / "splat-transform", f'''#!{sys.executable}
|
||||
import json, shutil, sys
|
||||
with open("{TOOL_LOG}", "a") as log:
|
||||
log.write(json.dumps(["splat-transform", *sys.argv[1:]]) + "\\n")
|
||||
args = [a for a in sys.argv[1:] if a not in ("-w", "--overwrite")]
|
||||
if "--decimate" in args:
|
||||
i = args.index("--decimate"); del args[i:i + 2]
|
||||
src, dst = args[0], args[-1]
|
||||
if dst.endswith(".ply"):
|
||||
shutil.copy(src, dst)
|
||||
else:
|
||||
open(dst, "wb").write(b"fake sog")
|
||||
print("done")
|
||||
''')
|
||||
# gio trash: moves into a trash folder of the test (numbered if a name repeats)
|
||||
script(BIN / "gio", f'''#!/bin/sh
|
||||
[ "$1" = trash ] || exit 1
|
||||
shift; [ "$1" = -- ] && shift
|
||||
exec mv --backup=numbered -t "{TRASH}" -- "$@"
|
||||
''')
|
||||
|
||||
# Nothing may open on the desktop, even if the code under test is broken: file dialogs, xdg-open (folder,
|
||||
# video player) only leave a note in the tool log
|
||||
for tool in ("kdialog", "zenity", "xdg-open"):
|
||||
script(BIN / tool, f'''#!{sys.executable}
|
||||
import json, sys
|
||||
with open("{TOOL_LOG}", "a") as log:
|
||||
log.write(json.dumps(["{tool}", *sys.argv[1:]]) + "\\n")
|
||||
''')
|
||||
# notify-send: no desktop notifications from the tests, only a note in the tool log
|
||||
script(BIN / "notify-send", f'''#!{sys.executable}
|
||||
import json, sys
|
||||
with open("{TOOL_LOG}", "a") as log:
|
||||
log.write(json.dumps(["notify-send", *sys.argv[1:]]) + "\\n")
|
||||
''')
|
||||
# ffprobe: a good Arcturus recording (two video streams, gpmd, 60 s) unless the file name says otherwise:
|
||||
# "mono" one video stream, "nogpmd" no metadata stream, "short" 5 s, "brief" 15 s
|
||||
script(BIN / "ffprobe", f'''#!{sys.executable}
|
||||
import json, sys
|
||||
name = sys.argv[-1]
|
||||
video = {{"codec_type": "video", "codec_tag_string": "hvc1", "width": 2464, "height": 2464, "avg_frame_rate": "30/1"}}
|
||||
streams = [video] if "mono" in name else [video, dict(video)]
|
||||
if "nogpmd" not in name:
|
||||
streams.append({{"codec_type": "data", "codec_tag_string": "gpmd"}})
|
||||
dur = 5 if "short" in name else 15 if "brief" in name else 60
|
||||
print(json.dumps({{"streams": streams, "format": {{"duration": str(dur)}}}}))
|
||||
''')
|
||||
# ssh / scp: the Frame is a folder (its home directory); FAKE_FRAME_OFF=1 makes it unreachable
|
||||
script(BIN / "ssh", f'''#!{sys.executable}
|
||||
import os, subprocess, sys
|
||||
if os.environ.get("FAKE_FRAME_OFF"):
|
||||
sys.exit(255)
|
||||
args = sys.argv[1:]
|
||||
while args and args[0].startswith("-"):
|
||||
args = args[2:] if args[0] in ("-o", "-p", "-i") else args[1:]
|
||||
cmd = " ".join(args[1:])
|
||||
env = {{**os.environ, "HOME": "{FRAME_HOME}"}}
|
||||
if os.environ.get("FAKE_FRAME_CUT") and cmd.startswith("cat "): # the connection breaks halfway
|
||||
out = subprocess.run(["bash", "-c", cmd], cwd="{FRAME_HOME}", env=env, capture_output=True).stdout
|
||||
sys.stdout.buffer.write(out[:len(out) // 2])
|
||||
sys.exit(255)
|
||||
sys.exit(subprocess.run(["bash", "-c", cmd or "true"], cwd="{FRAME_HOME}", env=env).returncode)
|
||||
''')
|
||||
script(BIN / "scp", f'''#!{sys.executable}
|
||||
import os, shutil, sys
|
||||
from pathlib import Path
|
||||
if os.environ.get("FAKE_FRAME_OFF"):
|
||||
sys.exit(255)
|
||||
args = [a for a in sys.argv[1:] if a not in ("-q",)]
|
||||
def local(a):
|
||||
if ":" in a.split("/", 1)[0]:
|
||||
return Path("{FRAME_HOME}") / a.split(":", 1)[1]
|
||||
return Path(a)
|
||||
*src, dst = map(local, args)
|
||||
for s in src:
|
||||
shutil.copy(s, dst / s.name if dst.is_dir() else dst)
|
||||
''')
|
||||
|
||||
(BASE / "config.toml").write_text(f'''
|
||||
[paths]
|
||||
pipeline = "{PIPE}"
|
||||
spirula = "{BIN / 'spirula'}"
|
||||
splat_transform = "{BIN / 'splat-transform'}"
|
||||
recordings = "{REC}"
|
||||
runs = "{RUNS}"
|
||||
extra_recording_dirs = []
|
||||
[server]
|
||||
language = "en"
|
||||
[frame]
|
||||
host = ""
|
||||
''')
|
||||
os.environ.update(SPLAT_WORKSHOP_CONFIG=str(BASE / "config.toml"), SPLAT_WORKSHOP_STATE=str(STATE), FRAME_HOST="",
|
||||
PATH=f"{BIN}:{os.environ['PATH']}")
|
||||
os.environ.pop("SPLAT_LAN", None)
|
||||
|
||||
from splat_workshop import server # noqa: E402 (after the environment is set up)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean():
|
||||
"""Every test starts with empty runs, recordings, state and trash."""
|
||||
yield
|
||||
st = server.load_state().get("job") or {}
|
||||
if server.pid_alive(st.get("pid", 0)):
|
||||
server.cancel_job()
|
||||
wait_job(timeout=20)
|
||||
for d in (RUNS, REC, STATE, TRASH, FRAME_HOME):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
d.mkdir()
|
||||
for cache in (server.usage_cache, server.probe_cache, server.uploads, server.checks):
|
||||
cache.clear()
|
||||
for k in ("FAKE_PIPELINE", "FAKE_FRAME_OFF", "FAKE_FRAME_CUT", "FRAME_HOST"):
|
||||
os.environ.pop(k, None)
|
||||
os.environ["FRAME_HOST"] = ""
|
||||
TOOL_LOG.unlink(missing_ok=True)
|
||||
server.frame_cache.update(t=0, data=None)
|
||||
|
||||
|
||||
SHUTDOWNS = [] # calls of server.shutdown during the current test
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def never_exit(monkeypatch):
|
||||
"""Quitting must never end the test run – a broken guard would otherwise make it stop silently (green)."""
|
||||
SHUTDOWNS.clear()
|
||||
monkeypatch.setattr(server, "shutdown", lambda cancel: SHUTDOWNS.append(cancel))
|
||||
|
||||
def no_exit(code=0):
|
||||
raise RuntimeError(f"os._exit({code}) called during a test")
|
||||
monkeypatch.setattr(server.os, "_exit", no_exit)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def frame(monkeypatch):
|
||||
"""A Steam Frame (the fake one: a folder) is configured and reachable. Returns its home directory."""
|
||||
host = "steamos@frame.test"
|
||||
monkeypatch.setattr(server, "FRAME", host)
|
||||
monkeypatch.setattr(server, "SSH", [*server.SSH[:-1], host])
|
||||
os.environ["FRAME_HOST"] = host # for the scripts (splat.sh, to-frame.sh)
|
||||
for d in (server.FRAME_VIDEOS + "/thumbnails", server.FRAME_SCENES):
|
||||
(FRAME_HOME / d).mkdir(parents=True, exist_ok=True)
|
||||
return FRAME_HOME
|
||||
|
||||
|
||||
def tools_called():
|
||||
"""Calls of the fake tools so far, e.g. [["splat-transform", "-w", ...], ...]."""
|
||||
return [json.loads(line) for line in TOOL_LOG.read_text().splitlines()] if TOOL_LOG.exists() else []
|
||||
|
||||
|
||||
def wait_for_tool(name, timeout=5):
|
||||
"""Calls of one fake tool, waiting a little for ones started in the background (notifications)."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
calls = [c for c in tools_called() if c[0] == name]
|
||||
if calls:
|
||||
return calls
|
||||
time.sleep(0.1)
|
||||
return []
|
||||
|
||||
|
||||
def recording(name="2026-10-12_16-40-00_stereo.mp4"):
|
||||
p = REC / name
|
||||
p.write_bytes(b"not really a video")
|
||||
return p
|
||||
|
||||
|
||||
def wait_job(timeout=120):
|
||||
"""Wait until the current job has ended; returns its status."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
js = server.job_status(tail=True)
|
||||
if js and not js["running"]:
|
||||
return js
|
||||
time.sleep(0.3)
|
||||
raise TimeoutError("job did not finish")
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, port):
|
||||
self.port = port
|
||||
|
||||
def request(self, method, path, body=None, headers=None):
|
||||
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=30)
|
||||
data = body if isinstance(body, (bytes, type(None))) else json.dumps(body).encode()
|
||||
c.request(method, path, body=data, headers={"Content-Type": "application/json", **(headers or {})})
|
||||
r = c.getresponse()
|
||||
raw = r.read()
|
||||
try:
|
||||
return r.status, json.loads(raw)
|
||||
except ValueError:
|
||||
return r.status, raw
|
||||
|
||||
def get(self, path, headers=None):
|
||||
return self.request("GET", path, headers=headers)
|
||||
|
||||
def post(self, path, body=None, headers=None):
|
||||
return self.request("POST", path, body if body is not None else {}, headers)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def api():
|
||||
srv = server.http.server.ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
|
||||
srv.daemon_threads = True
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
yield Client(srv.server_address[1])
|
||||
srv.shutdown()
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
shutil.rmtree(BASE, ignore_errors=True)
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
"""Synthetic rooms in the shape the Arcturus pipeline leaves them, for tests without real recordings.
|
||||
|
||||
Two rooms side by side with a door between them (Y points down like in the pipeline):
|
||||
|
||||
z=3 +----------------+----------------+
|
||||
| | |
|
||||
| room A door room B |
|
||||
| (x 0..4) | (x 4..8) |
|
||||
z=0 +----------------+----------------+
|
||||
x=0 x=4 x=8
|
||||
|
||||
Every recording walks around its room; room B starts in room A next to the door and walks in
|
||||
(like a real recording that starts in the hallway). Each room gets what the pipeline writes:
|
||||
dataset/main.aiscene camera poses as tracked by the Frame (the truth, plus `drift`)
|
||||
dataset/sparse/0/images.bin camera poses of the reconstruction
|
||||
dataset/sparse/0/points3D.bin sparse points
|
||||
spirula/scene.ply splats (one per point)
|
||||
scene.sog, scene.xrSceneTransform.json, <slug>.ui.json
|
||||
The reconstruction is off by a known small rotation and shift (`error`), as a real one is before
|
||||
it is fitted to the Frame's tracking.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
FLOOR, CEILING = 1.6, -0.9 # y (down): floor below the cameras, ceiling above
|
||||
DOOR = (1.2, 2.0) # door between the rooms, z range
|
||||
PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
|
||||
PROPS = (["x", "y", "z", "nx", "ny", "nz", "f_dc_0", "f_dc_1", "f_dc_2"] + [f"f_rest_{i}" for i in range(45)]
|
||||
+ ["opacity", "scale_0", "scale_1", "scale_2", "rot_0", "rot_1", "rot_2", "rot_3"])
|
||||
|
||||
|
||||
def walls(x0, x1, step=0.04):
|
||||
"""Points on the walls, floor and ceiling of the room x0..x1 (z 0..3), with a door in the wall at x=4."""
|
||||
pts = []
|
||||
ys = np.arange(CEILING, FLOOR + 1e-9, step)
|
||||
for x in (x0, x1):
|
||||
for z in np.arange(0, 3 + 1e-9, step):
|
||||
if x == 4 and DOOR[0] < z < DOOR[1]:
|
||||
continue
|
||||
pts += [(x, y, z) for y in ys]
|
||||
for z in (0, 3):
|
||||
for x in np.arange(x0, x1 + 1e-9, step):
|
||||
pts += [(x, y, z) for y in ys]
|
||||
for x in np.arange(x0, x1 + 1e-9, step * 2):
|
||||
for z in np.arange(0, 3 + 1e-9, step * 2):
|
||||
pts += [(x, FLOOR, z), (x, CEILING, z)]
|
||||
return np.array(pts)
|
||||
|
||||
|
||||
def path(room, walk_in_offset=0.3):
|
||||
"""Camera positions over time (y = 0, head height) and the times in seconds."""
|
||||
t = np.linspace(0, 2 * np.pi, 140)
|
||||
if room == "A":
|
||||
# starts at the far wall, passes the door in the middle of the round
|
||||
loop = np.stack([2 + 1.3 * np.cos(t + np.pi), np.zeros_like(t), 1.5 + 0.9 * np.sin(t + np.pi)], 1)
|
||||
return loop, np.linspace(0, 40, len(loop))
|
||||
# starts in room A next to room A's round (like a recording that starts in the hallway), then through
|
||||
# the door. walk_in_offset: how far from room A's own path (towards room A's wall) it starts.
|
||||
tt = np.linspace(-1.0, 0, 18)
|
||||
along_a = np.stack([2 + 1.3 * np.cos(tt), np.zeros_like(tt), 1.5 + 0.9 * np.sin(tt) - walk_in_offset], 1)
|
||||
walk_in = np.vstack([along_a, np.stack([np.linspace(3.35, 4.6, 12), np.zeros(12), np.full(12, 1.6)], 1)])
|
||||
loop = np.stack([6 + 1.3 * np.cos(t + np.pi), np.zeros_like(t), 1.5 + 0.9 * np.sin(t + np.pi)], 1)
|
||||
pos = np.vstack([walk_in, loop])
|
||||
return pos, np.linspace(0, 45, len(pos))
|
||||
|
||||
|
||||
def look_at(c, target):
|
||||
"""cam_from_world rotation for a camera at c looking at target (x right, y down, z forward)."""
|
||||
z = target - c
|
||||
z[1] = 0
|
||||
z /= np.linalg.norm(z)
|
||||
y = np.array([0.0, 1.0, 0.0])
|
||||
x = np.cross(y, z)
|
||||
return np.stack([x, y, z])
|
||||
|
||||
|
||||
def write_images(path_, names, R_list, C_list):
|
||||
with open(path_, "wb") as f:
|
||||
f.write(struct.pack("<Q", len(names)))
|
||||
for i, (name, R, C) in enumerate(zip(names, R_list, C_list)):
|
||||
qx, qy, qz, qw = Rotation.from_matrix(R).as_quat()
|
||||
t = -R @ C
|
||||
f.write(struct.pack("<i7di", i + 1, qw, qx, qy, qz, *t, 1))
|
||||
f.write(name.encode() + b"\0")
|
||||
f.write(struct.pack("<Q", 0))
|
||||
|
||||
|
||||
def write_points(path_, pts):
|
||||
with open(path_, "wb") as f:
|
||||
f.write(struct.pack("<Q", len(pts)))
|
||||
for i, p in enumerate(pts):
|
||||
f.write(struct.pack("<Q3d3BdQ", i + 1, *p, 128, 128, 128, 0.5, 0))
|
||||
|
||||
|
||||
def write_ply(path_, pts, colours, rotation=(1.0, 0.0, 0.0, 0.0), scale=0.02):
|
||||
"""One splat per point; all with the same rotation (w, x, y, z) and size (metres)."""
|
||||
data = np.zeros(len(pts), dtype=np.dtype([(p, "<f4") for p in PROPS]))
|
||||
data["x"], data["y"], data["z"] = pts.T
|
||||
for k in range(3):
|
||||
data[f"f_dc_{k}"] = (colours[:, k] - 0.5) / 0.28209479177387814
|
||||
data["opacity"] = 3.0
|
||||
for k in range(3):
|
||||
data[f"scale_{k}"] = np.log(scale)
|
||||
for k in range(4):
|
||||
data[f"rot_{k}"] = rotation[k]
|
||||
header = "ply\nformat binary_little_endian 1.0\nelement vertex %d\n%send_header\n" % (
|
||||
len(pts), "".join(f"property float {p}\n" for p in PROPS))
|
||||
with open(path_, "wb") as f:
|
||||
f.write(header.encode())
|
||||
data.tofile(f)
|
||||
|
||||
|
||||
MARKERS = (((1, 0, 0), (0.0, 0.5, 0.0)), ((0, 1, 0), (2.0, 0.5, 0.0)), ((0, 0, 1), (0.0, 0.5, 2.0)))
|
||||
SPLAT_TURN = Rotation.from_euler("x", 20, degrees=True) # how every splat is turned in the real world
|
||||
SPLAT_SIZE = 0.02 # metres
|
||||
|
||||
|
||||
def make_room(runs: Path, slug: str, room: str, error=(1.0, (0.03, 0.0, -0.02)), scale=1.0,
|
||||
drift=(0.0, 0.0, 0.0), turn=0.0, noise=0.005, walk_in_offset=0.3,
|
||||
name=None, recording=None, markers=(), colour=None):
|
||||
"""Write one synthetic room into runs/<slug>. Returns the run folder.
|
||||
|
||||
The reconstruction differs from the real world by `error` = (yaw in degrees, shift in m) and `scale`.
|
||||
The Frame's tracking of this recording is off by `drift` (m) and turned by `turn` (degrees, about the
|
||||
room's middle), plus `noise` (m) per pose. markers: True for red/green/blue at the corner, along +x and
|
||||
along +z, or a list of ((r, g, b), (x, y, z)) – blobs of 400 splats at real-world positions.
|
||||
runs/<slug>/synth.json records how many splats are the room's own (of these, how many on the wall it
|
||||
shares with the other room – zero thickness, so it belongs to both) and how many were seen through the door."""
|
||||
rng = np.random.default_rng(zlib.crc32(slug.encode()))
|
||||
run = runs / slug
|
||||
sparse = run / "dataset" / "sparse" / "0"
|
||||
sparse.mkdir(parents=True, exist_ok=True)
|
||||
(run / "spirula").mkdir(exist_ok=True)
|
||||
x0, x1 = (0.0, 4.0) if room == "A" else (4.0, 8.0)
|
||||
pts = walls(x0, x1)
|
||||
# every recording its own colour, so the origin of a splat can be seen after putting rooms together
|
||||
colours = np.tile(colour or ((0.8, 0.3, 0.3) if room == "A" else (0.3, 0.3, 0.8)), (len(pts), 1))
|
||||
for c, at in (MARKERS if markers is True else markers):
|
||||
blob = np.array(at) + rng.normal(0, 0.05, (400, 3))
|
||||
pts, colours = np.vstack([pts, blob]), np.vstack([colours, np.tile(c, (400, 1))])
|
||||
own = len(pts)
|
||||
shared = int((np.abs(pts[:, 0] - 4) < 1e-9).sum()) # the wall between the rooms belongs to both
|
||||
# seen through the door: a little of the neighbouring room close to the door
|
||||
other = walls(4.0, 8.0) if room == "A" else walls(0.0, 4.0)
|
||||
near_door = other[(np.abs(other[:, 0] - 4) < 1.0) & (other[:, 2] > 0.6) & (other[:, 2] < 2.6)]
|
||||
pts = np.vstack([pts, near_door])
|
||||
colours = np.vstack([colours, np.tile(colours[0], (len(near_door), 1))])
|
||||
|
||||
pos, times = path(room, walk_in_offset)
|
||||
centre = np.array([(x0 + x1) / 2, 0.0, 1.5])
|
||||
yaw, shift = error
|
||||
E = Rotation.from_euler("y", yaw, degrees=True) # world -> reconstruction
|
||||
to_sfm = lambda p: scale * E.apply(p) + np.array(shift)
|
||||
T = Rotation.from_euler("y", turn, degrees=True) # the tracking's own turn
|
||||
names, Rs, Cs, frames = [], [], [], []
|
||||
for k, (c, t) in enumerate(zip(pos, times)):
|
||||
R_world = look_at(c.copy(), centre)
|
||||
image = f"cam0/{k:04d}-{t:.6f}.jpg"
|
||||
names.append(image)
|
||||
Rs.append(R_world @ E.as_matrix().T)
|
||||
Cs.append(to_sfm(c))
|
||||
world_from_cam = np.eye(4)
|
||||
world_from_cam[:3, :3] = T.as_matrix() @ R_world.T
|
||||
world_from_cam[:3, 3] = T.apply(c - centre) + centre + np.array(drift) + rng.normal(0, noise, 3)
|
||||
frames.append({"cam": 0, "path": "images/" + image, "worldFromCam": world_from_cam.tolist(),
|
||||
"playbackTimestampSec": float(t)})
|
||||
write_images(sparse / "images.bin", names, Rs, Cs)
|
||||
write_points(sparse / "points3D.bin", to_sfm(pts))
|
||||
q = (E * SPLAT_TURN).as_quat() # (x, y, z, w) in the reconstruction
|
||||
write_ply(run / "spirula" / "scene.ply", to_sfm(pts), colours, (q[3], q[0], q[1], q[2]), SPLAT_SIZE * scale)
|
||||
(run / "dataset" / "main.aiscene").write_text(json.dumps({"format": "synthetic", "cameras": [], "frames": frames}))
|
||||
(run / "scene.sog").write_bytes(b"synthetic")
|
||||
(run / "scene.xrSceneTransform.json").write_text(json.dumps({"transform": np.eye(4).tolist(), "worldScale": 1.0}))
|
||||
(run / f"{slug.lower()}.gallery.png").write_bytes(PNG)
|
||||
(run / "synth.json").write_text(json.dumps({"own": own, "shared_wall": shared, "seen_through_door": len(near_door),
|
||||
"splats": len(pts)}))
|
||||
(runs / f"{slug}.log").write_text(f"spirula: {len(pts):,} Gaussians in {run}/spirula/scene.ply\n")
|
||||
meta = {"name": name or slug, "recording": recording or f"2026-10-12_16-{40 + (room == 'B') * 3:02d}-00_stereo.mp4",
|
||||
"quality": "high"}
|
||||
(runs / f"{slug}.ui.json").write_text(json.dumps(meta))
|
||||
return run
|
||||
@@ -0,0 +1,103 @@
|
||||
"""The recording check's rating (splat_workshop/check.py): per-second measurements in, findings out.
|
||||
|
||||
Rows are made by hand with values clearly on one side of each limit, so the tests say what a user
|
||||
gets for a recording that is, say, 50 % dark – not what the code happens to compute."""
|
||||
import pytest
|
||||
|
||||
from splat_workshop import check
|
||||
|
||||
GOOD = {"bright": 120, "detail": 7.0, "motion": 10.0, "over": 0.0, "cast": 5, "rgb": (120, 120, 120),
|
||||
"right": {"bright": 120, "detail": 7.0}}
|
||||
|
||||
|
||||
def rows(n=60, **changes_by_second):
|
||||
"""n good seconds; changes_by_second: {"dark": range(...), ...} turns those seconds bad in one way."""
|
||||
out = [dict(GOOD, right=dict(GOOD["right"])) for _ in range(n)]
|
||||
for kind, secs in changes_by_second.items():
|
||||
for i in secs:
|
||||
r = out[i]
|
||||
if kind == "dark":
|
||||
r["bright"] = r["right"]["bright"] = 20
|
||||
elif kind == "flat":
|
||||
r["detail"] = r["right"]["detail"] = 1.0
|
||||
elif kind == "fast":
|
||||
r["motion"] = 80.0
|
||||
elif kind == "over":
|
||||
r["over"] = 0.2
|
||||
elif kind == "eye": # one lens covered
|
||||
r["right"] = {"bright": 30, "detail": 0.5}
|
||||
return out
|
||||
|
||||
|
||||
def findings(res):
|
||||
return {f["code"]: f["level"] for f in res["findings"]}
|
||||
|
||||
|
||||
def test_a_good_recording():
|
||||
res = check.evaluate(rows(), 60)
|
||||
assert res["level"] == "ok" and res["findings"] == [] and set(res["timeline"]) == {"ok"}
|
||||
assert res["summary"] == {"bright": 120, "detail": 7.0, "seconds": 60}
|
||||
|
||||
|
||||
def test_nothing_measured():
|
||||
assert check.evaluate([], 0)["level"] == "bad"
|
||||
|
||||
|
||||
def test_too_dark_overall():
|
||||
dark = [dict(r, bright=20, right={"bright": 20, "detail": 7.0}) for r in rows()] # both lenses
|
||||
assert findings(check.evaluate(dark, 60)) == {"too_dark": "bad"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("secs,level", [(range(0, 30), "bad"), # half the recording dark
|
||||
(range(0, 24), "warn"), # 40 %: not more than 40 %
|
||||
(range(0, 3), "warn")])
|
||||
def test_dark_stretches(secs, level):
|
||||
res = check.evaluate(rows(dark=secs), 60)
|
||||
assert findings(res)["dark"] == level
|
||||
f = next(f for f in res["findings"] if f["code"] == "dark")
|
||||
assert f["secs"] == len(secs) and f["spans"] == [(secs[0], secs[-1])]
|
||||
assert res["timeline"][secs[0]] == "dark"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("secs,level", [(range(10, 40), "bad"), (range(10, 15), "warn"), (range(10, 13), "info")])
|
||||
def test_plain_surfaces(secs, level):
|
||||
# 30 s = 50 %: bad; 5 s = 8 %: warn; 3 s = 5 %: only a note
|
||||
assert findings(check.evaluate(rows(flat=secs), 60))["flat"] == level
|
||||
|
||||
|
||||
def test_a_single_plain_second_is_not_worth_a_finding():
|
||||
assert "flat" not in findings(check.evaluate(rows(flat=[10]), 60))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("secs,level", [(range(0, 12), "warn"), (range(0, 5), "info")])
|
||||
def test_fast_pans(secs, level):
|
||||
assert findings(check.evaluate(rows(fast=secs), 60))["fast"] == level
|
||||
|
||||
|
||||
def test_a_covered_lens_needs_three_seconds():
|
||||
assert findings(check.evaluate(rows(eye=range(5, 8)), 60))["cameras_differ"] == "warn"
|
||||
assert "cameras_differ" not in findings(check.evaluate(rows(eye=range(5, 7)), 60))
|
||||
|
||||
|
||||
def test_overexposure_and_colour_cast_are_notes_only():
|
||||
tinted = [dict(r, cast=30, rgb=(160, 140, 80)) for r in rows(over=range(0, 5))]
|
||||
res = check.evaluate(tinted, 60)
|
||||
assert findings(res) == {"overexposed": "info", "color_cast": "info"}
|
||||
assert res["level"] == "ok"
|
||||
assert next(f for f in res["findings"] if f["code"] == "color_cast")["tone"] == "yellow"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("duration,code,level", [(15, "short", "warn"), (200, "long", "info")])
|
||||
def test_length(duration, code, level):
|
||||
assert findings(check.evaluate(rows(), duration))[code] == level
|
||||
|
||||
|
||||
def test_the_worst_finding_decides():
|
||||
res = check.evaluate(rows(dark=range(0, 30), fast=range(40, 45)), 60)
|
||||
assert res["level"] == "bad"
|
||||
assert res["timeline"][0] == "dark" and res["timeline"][42] == "fast" and res["timeline"][50] == "ok"
|
||||
|
||||
|
||||
def test_spans():
|
||||
assert check._spans([False, True, True, False, True]) == [(1, 2), (4, 4)]
|
||||
assert check._spans([True, False, True, True], min_len=2) == [(2, 3)]
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Putting rooms together (scripts/combine.py, scripts/combine.sh) on synthetic rooms with a known error.
|
||||
|
||||
The expectations come from how the synthetic rooms were built (tests/synth.py), not from the code:
|
||||
each reconstruction is turned, shifted and scaled by a known amount, the Frame's tracking has 5 mm
|
||||
noise and optionally a drift or a turn of its own."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
from conftest import BASE, ROOT, RUNS, tools_called
|
||||
import synth
|
||||
|
||||
COMBINE = ROOT / "scripts" / "combine.py"
|
||||
DRIFT_B = 0.03 # metres: the Frame's tracking of room B is off by this much
|
||||
|
||||
|
||||
def two_rooms(drift_b=DRIFT_B, turn_b=0.0, b_far=False, walk_in_offset=0.3, scale_a=1.0, markers_b=()):
|
||||
a = synth.make_room(RUNS, "A", "A", error=(1.0, (0.03, 0.0, -0.02)), scale=scale_a,
|
||||
markers=[((0, 1, 0), (1.0, 0.5, 0.3)), ((0, 1, 1), (1.0, 0.5, 2.7))])
|
||||
b = synth.make_room(RUNS, "B", "B", error=(-0.8, (-0.02, 0.01, 0.03)), turn=turn_b,
|
||||
drift=(20.0 if b_far else drift_b, 0.0, 0.0), walk_in_offset=walk_in_offset,
|
||||
markers=markers_b)
|
||||
return a, b
|
||||
|
||||
|
||||
def combine(*runs, align="images", partition="view"):
|
||||
out = BASE / f"out-{align}-{partition}"
|
||||
r = subprocess.run([sys.executable, str(COMBINE), str(out), *[f"{p}={p.name}" for p in runs],
|
||||
"--align", align, "--partition", partition], capture_output=True, text=True)
|
||||
assert r.returncode == 0 or "@@ error" in r.stdout, r.stderr
|
||||
return out, r
|
||||
|
||||
|
||||
def result(out):
|
||||
return json.loads((out / "combine.json").read_text())
|
||||
|
||||
|
||||
def read_ply(path):
|
||||
with open(path, "rb") as f:
|
||||
header = b""
|
||||
while not header.endswith(b"end_header\n"):
|
||||
header += f.readline()
|
||||
props = [l.split()[-1].decode() for l in header.splitlines() if l.startswith(b"property")]
|
||||
n = int(next(l for l in header.splitlines() if l.startswith(b"element vertex")).split()[-1])
|
||||
return np.fromfile(f, dtype=np.dtype([(p, "<f4") for p in props]), count=n)
|
||||
|
||||
|
||||
def blob(v, rgb):
|
||||
"""Centre of the marker splats of one colour (markers are pure colours, rooms are not)."""
|
||||
c = 0.5 + 0.28209479177387814 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1)
|
||||
m = np.all(np.abs(c - np.array(rgb)) < 0.02, axis=1)
|
||||
assert m.sum() >= 300, f"marker {rgb} not found"
|
||||
return np.stack([v["x"], v["y"], v["z"]], 1)[m].mean(0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("align,lo,hi", [
|
||||
("frame", 5.0, 12.0), # as delivered: both reconstructions' errors add up
|
||||
("images", 2.0, 4.0), # rooms fitted, but room B's tracking drift (3 cm) is left
|
||||
("shift", 0.0, 1.0), # shifting the rooms over the door removes the drift
|
||||
("rigid", 0.0, 1.0),
|
||||
])
|
||||
def test_corrections_take_out_the_known_error(align, lo, hi):
|
||||
out, r = combine(*two_rooms(), align=align)
|
||||
seam = result(out)["seams"]["A|B"]["cm"]
|
||||
assert lo <= seam <= hi, f"{align}: overlap off by {seam} cm"
|
||||
|
||||
|
||||
def test_every_room_is_fitted_to_the_frames_tracking():
|
||||
out, r = combine(*two_rooms(drift_b=0.0, scale_a=1.03))
|
||||
for room in result(out)["rooms"].values():
|
||||
assert room["tracking_cm"] > 3 # off before: turned, shifted (and room A 3 % too big)
|
||||
assert room["fitted_cm"] < 1.5 # fitted over all images: down to the 5 mm tracking noise
|
||||
|
||||
|
||||
def test_splats_get_the_rotation_and_size_they_have_in_reality():
|
||||
"""Room A's reconstruction is 3 % too big and turned by 1°; afterwards every splat must have its
|
||||
real-world orientation (20° about x) and size (2 cm) again – with the right sign."""
|
||||
out, r = combine(*two_rooms(drift_b=0.0, scale_a=1.03))
|
||||
v = read_ply(out / "spirula" / "scene.ply")
|
||||
q = np.stack([v["rot_1"], v["rot_2"], v["rot_3"], v["rot_0"]], 1).astype(np.float64)
|
||||
off = (Rotation.from_quat(q) * synth.SPLAT_TURN.inv()).magnitude()
|
||||
assert np.degrees(off).max() < 0.3, "splats are turned the wrong way"
|
||||
size = np.exp(np.stack([v["scale_0"], v["scale_1"], v["scale_2"]], 1).astype(np.float64))
|
||||
assert np.allclose(size, synth.SPLAT_SIZE, rtol=0.01), "splats have the wrong size"
|
||||
# and the room itself has its real size: the two markers in room A are 2.4 m apart
|
||||
assert abs(np.linalg.norm(blob(v, (0, 1, 1)) - blob(v, (0, 1, 0))) - 2.4) < 0.02
|
||||
|
||||
|
||||
def test_shift_and_turn_also_takes_out_a_turned_tracking():
|
||||
"""Room B's tracking is turned by 2° about the room's middle: shifting (C) cannot repair that,
|
||||
shifting and turning (D) can reduce it. Measured independently of the alignment: the distances from
|
||||
the markers in room A to a marker in the far corner of room B (rigid motions keep distances).
|
||||
Known limit: the turn can only be read from the overlap at the door, so D does not take it out
|
||||
completely (here about 2 cm are left of about 6 cm)."""
|
||||
far_b = ((1, 0, 1), (7.5, 0.5, 2.7))
|
||||
truth = [np.linalg.norm(np.array(far_b[1]) - np.array(p)) for p in ((1.0, 0.5, 0.3), (1.0, 0.5, 2.7))]
|
||||
|
||||
def error(align):
|
||||
out, r = combine(*two_rooms(drift_b=0.0, turn_b=2.0, markers_b=[far_b]), align=align)
|
||||
v = read_ply(out / "spirula" / "scene.ply")
|
||||
got = [np.linalg.norm(blob(v, far_b[0]) - blob(v, c)) for c in ((0, 1, 0), (0, 1, 1))]
|
||||
return max(abs(g - t) for g, t in zip(got, truth)) * 100
|
||||
|
||||
shift, rigid = error("shift"), error("rigid")
|
||||
assert shift > 3, f"the test needs a turn that shifting cannot repair ({shift:.1f} cm)"
|
||||
assert rigid < shift / 2, f"shift {shift:.1f} cm, shift and turn {rigid:.1f} cm"
|
||||
|
||||
|
||||
def test_every_splat_is_written_once():
|
||||
a, b = two_rooms()
|
||||
out, r = combine(a, b)
|
||||
info, v = result(out), read_ply(out / "spirula" / "scene.ply")
|
||||
assert len(v) == info["splats"]
|
||||
for run in (a, b):
|
||||
made = json.loads((run / "synth.json").read_text())
|
||||
room = info["rooms"][run.name]
|
||||
assert room["splats"] == made["splats"] # read completely
|
||||
# a room keeps its own splats (the shared wall may go either way); of what it saw of the neighbour
|
||||
# through the door it keeps at most the part right behind the door, where its own cameras were closer
|
||||
assert 0.95 * (made["own"] - made["shared_wall"]) <= room["kept"] <= made["own"] + made["seen_through_door"]
|
||||
# each place once: together the rooms keep no more than they are made of
|
||||
total_own = sum(json.loads((run / "synth.json").read_text())["own"] for run in (a, b))
|
||||
assert len(v) <= total_own
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offset", [0.1, 0.3])
|
||||
def test_borders_run_through_the_door(offset):
|
||||
"""Room B starts its recording in room A (walking in). With borders 'through doors' that stretch
|
||||
claims nothing, so room A comes from room A's own recording; 'nearest camera' cuts into room A."""
|
||||
def from_b_in_room_a(partition):
|
||||
out, r = combine(*two_rooms(drift_b=0.0, walk_in_offset=offset), partition=partition)
|
||||
v = read_ply(out / "spirula" / "scene.ply")
|
||||
inside_a = (v["x"] > 0.2) & (v["x"] < 3.6)
|
||||
c = 0.5 + 0.28209479177387814 * np.stack([v["f_dc_0"], v["f_dc_1"], v["f_dc_2"]], 1)
|
||||
from_b = np.all(np.abs(c - (0.3, 0.3, 0.8)) < 0.02, axis=1) # room B's own colour
|
||||
return int((inside_a & from_b).sum()), result(out)["rooms"]["B"]["walk_in_out_cameras"]
|
||||
view, walk_in = from_b_in_room_a("view")
|
||||
assert walk_in > 10 # recognised as walking in
|
||||
assert view < 50 # practically nothing of room B inside room A
|
||||
if offset >= 0.3: # close to the round, room A's cameras win anyway
|
||||
nearest, _ = from_b_in_room_a("nearest")
|
||||
assert nearest > 10 * max(view, 5)
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=True, reason="known limit: walking in is only recognised up to 0.4 m from the other "
|
||||
"recording's round; further away the walk-in stretch claims room A's wall")
|
||||
def test_borders_with_a_walk_in_far_from_the_other_round():
|
||||
out, r = combine(*two_rooms(drift_b=0.0, walk_in_offset=0.6))
|
||||
assert result(out)["rooms"]["B"]["walk_in_out_cameras"] > 10
|
||||
|
||||
|
||||
def test_rooms_from_different_sessions_are_refused():
|
||||
out, r = combine(*two_rooms(b_far=True))
|
||||
assert r.returncode != 0
|
||||
assert "@@ error not_connected" in r.stdout
|
||||
|
||||
|
||||
def run_combine_sh(env, *runs):
|
||||
return subprocess.run([str(ROOT / "scripts" / "combine.sh"), "Tour", "Tour", *[f"{p}={p.name}" for p in runs]],
|
||||
env={**os.environ, "NO_UPLOAD": "1", **env}, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_combine_sh_reduces_for_the_frame_and_can_skip_the_full_version():
|
||||
a, b = two_rooms()
|
||||
r = run_combine_sh({"FRAME_SPLATS": "10000", "FULL": "0"}, a, b)
|
||||
assert r.returncode == 0, r.stdout[-2000:] + r.stderr[-2000:]
|
||||
calls = [c[1:] for c in tools_called() if c[0] == "splat-transform"]
|
||||
tmp = str(RUNS / ".building" / "Tour")
|
||||
assert calls[0] == ["-w", f"{tmp}/spirula/scene.ply", "--decimate", "10000", f"{tmp}/frame.ply"]
|
||||
assert calls[1] == ["-w", f"{tmp}/frame.ply", f"{tmp}/scene.sog"]
|
||||
assert len(calls) == 2 # no full version
|
||||
tour = RUNS / "Tour"
|
||||
assert (tour / "scene.sog").exists() and not (tour / "full").exists() and not (tour / "frame.ply").exists()
|
||||
assert not (RUNS / ".building").exists()
|
||||
|
||||
|
||||
def test_combine_sh_full_version_without_reducing_a_small_tour():
|
||||
a, b = two_rooms()
|
||||
r = run_combine_sh({"FRAME_SPLATS": "3500000"}, a, b)
|
||||
assert r.returncode == 0, r.stdout[-2000:] + r.stderr[-2000:]
|
||||
calls = [c[1:] for c in tools_called() if c[0] == "splat-transform"]
|
||||
assert not any("--decimate" in c for c in calls) # fewer splats than the Frame version allows
|
||||
assert (RUNS / "Tour" / "full" / "tour-full.sog").exists()
|
||||
assert (RUNS / "Tour" / "full" / "tour-full.sceneinfo.png").exists()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""The floor plan is a top view, not its mirror image (scripts/floorplan.py).
|
||||
|
||||
A plan may be turned (so the walls are straight), but never mirrored. Three coloured markers tell
|
||||
the two apart: red at the corner, green along +x, blue along +z. Turning keeps the sense of
|
||||
red -> green -> blue, mirroring reverses it. Seen from above with the y axis pointing down
|
||||
(like the pipeline's frame), +x to +z turns counter-clockwise on screen.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from conftest import BASE, ROOT
|
||||
import synth
|
||||
|
||||
|
||||
def draw(style):
|
||||
runs = BASE / f"plan-{style}"
|
||||
synth.make_room(runs, "Room", "A", error=(0.0, (0, 0, 0)), markers=True)
|
||||
out = runs / "plan.png"
|
||||
r = subprocess.run([sys.executable, str(ROOT / "scripts" / "floorplan.py"), str(out), f"{runs / 'Room'}=Room",
|
||||
"--style", style], capture_output=True, text=True)
|
||||
assert r.returncode == 0, r.stderr
|
||||
return np.asarray(Image.open(out).convert("RGB")).astype(int)
|
||||
|
||||
|
||||
def test_floor_plan_is_not_mirrored():
|
||||
img = draw("pretty") # the pretty style shows the splats' own colours
|
||||
rr, gg, bb = img[..., 0], img[..., 1], img[..., 2]
|
||||
|
||||
def centre(mask):
|
||||
ys, xs = np.nonzero(mask)
|
||||
assert len(xs) > 50, "marker not found"
|
||||
return np.array([xs.mean(), ys.mean()])
|
||||
red = centre((rr > 150) & (gg < 90) & (bb < 90))
|
||||
green = centre((gg > 150) & (rr < 90) & (bb < 90))
|
||||
blue = centre((bb > 150) & (rr < 90) & (gg < 90))
|
||||
gx, gz = green - red, blue - red # screen directions of +x and +z (rows count downwards)
|
||||
cross = gx[0] * gz[1] - gx[1] * gz[0]
|
||||
assert cross < 0, "the floor plan is mirrored"
|
||||
|
||||
|
||||
def test_classic_style_smoke():
|
||||
"""Smoke test: the classic style draws something (its colours are per room, not checkable here)."""
|
||||
img = draw("classic")
|
||||
assert img.shape[0] > 500 and img.std() > 5 # not an empty picture
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Groups of scenes: one group per scene, suggestions, renaming, cleaning up together."""
|
||||
import json
|
||||
|
||||
from conftest import RUNS, server
|
||||
import synth
|
||||
|
||||
|
||||
def make_flat():
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen", recording="2026-10-12_16-40-00_stereo.mp4")
|
||||
synth.make_room(RUNS, "Hallway", "B", name="Hallway", recording="2026-10-12_16-43-00_stereo.mp4")
|
||||
synth.make_room(RUNS, "Statue", "A", name="Statue", recording="2026-10-03_19-00-00_stereo.mp4")
|
||||
|
||||
|
||||
def test_rooms_recorded_one_after_another_are_suggested(api):
|
||||
make_flat()
|
||||
# 25 minutes after the hallway: more than the 20 minutes that still count as one session
|
||||
synth.make_room(RUNS, "Garden", "A", name="Garden", recording="2026-10-12_17-08-00_stereo.mp4")
|
||||
status, r = api.get("/api/groups")
|
||||
assert status == 200 and r["groups"] == []
|
||||
assert sorted(r["suggestion"]["members"]) == ["Hallway", "Kitchen"] # not the statue from another day
|
||||
assert r["suggestion"]["date"] == "2026-10-12"
|
||||
assert api.post("/api/group/dismiss", {"key": r["suggestion"]["key"]})[0] == 200
|
||||
assert api.get("/api/groups")[1]["suggestion"] is None
|
||||
|
||||
|
||||
def test_a_group_with_its_scenes(api):
|
||||
make_flat()
|
||||
status, r = api.post("/api/group", {"name": "Example home", "members": "Kitchen,Hallway"})
|
||||
assert status == 200 and r["slug"] == "Example-home"
|
||||
g = api.get("/api/groups")[1]["groups"][0]
|
||||
assert (g["name"], g["members"], g["result"]) == ("Example home", ["Kitchen", "Hallway"], None)
|
||||
assert "Example-home" not in {s["slug"] for s in api.get("/api/scenes")[1]} # a group is not a scene
|
||||
assert "Example-home" not in {u["slug"] for u in api.get("/api/unfinished")[1]}
|
||||
|
||||
|
||||
def test_a_scene_can_only_be_in_one_group(api):
|
||||
make_flat()
|
||||
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hallway"})
|
||||
status, r = api.post("/api/group", {"name": "Other", "members": "Kitchen,Statue"})
|
||||
assert status == 400 and r["error"]["code"] == "err.in_other_group"
|
||||
assert api.post("/api/group", {"name": "Empty", "members": ""})[0] == 400
|
||||
assert api.post("/api/group", {"name": "Ghost", "members": "Nope"})[0] == 400
|
||||
|
||||
|
||||
def test_renaming_and_changing_a_group(api):
|
||||
make_flat()
|
||||
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hallway"})
|
||||
status, r = api.post("/api/group", {"slug": "Home", "name": "Old flat", "members": "Kitchen"})
|
||||
assert status == 200 and r["slug"] == "Old-flat"
|
||||
assert not (RUNS / "Home.ui.json").exists()
|
||||
g = api.get("/api/groups")[1]["groups"][0]
|
||||
assert (g["slug"], g["name"], g["members"]) == ("Old-flat", "Old flat", ["Kitchen"])
|
||||
|
||||
|
||||
def test_renamed_and_deleted_scenes_follow_their_group(api):
|
||||
make_flat()
|
||||
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hallway"})
|
||||
api.post("/api/rename", {"slug": "Kitchen", "new": "Küche"})
|
||||
assert json.loads((RUNS / "Home.ui.json").read_text())["members"] == ["Kueche", "Hallway"]
|
||||
api.post("/api/delete", {"slug": "Hallway"})
|
||||
assert json.loads((RUNS / "Home.ui.json").read_text())["members"] == ["Kueche"]
|
||||
|
||||
|
||||
def test_cleaning_up_a_group_cleans_only_its_scenes(api):
|
||||
make_flat()
|
||||
for slug in ("Kitchen", "Hallway", "Statue"):
|
||||
(RUNS / slug / "spirula" / "training").mkdir(parents=True)
|
||||
(RUNS / slug / "spirula" / "training" / "x").write_bytes(b"x" * 1000)
|
||||
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hallway"})
|
||||
assert api.post("/api/clean", {"slug": "Home", "what": "temp"})[0] == 200
|
||||
assert not (RUNS / "Kitchen/spirula/training").exists() and not (RUNS / "Hallway/spirula/training").exists()
|
||||
assert (RUNS / "Statue/spirula/training/x").exists()
|
||||
|
||||
|
||||
def test_dissolving_a_group_keeps_its_scenes(api):
|
||||
make_flat()
|
||||
api.post("/api/group", {"name": "Home", "members": "Kitchen,Hallway"})
|
||||
assert api.post("/api/delete", {"slug": "Home"})[0] == 200
|
||||
assert api.get("/api/groups")[1]["groups"] == []
|
||||
assert {"Kitchen", "Hallway", "Statue"} <= {s["slug"] for s in server.scenes()}
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Conversions and tours as real background jobs (scripts/splat.sh, scripts/combine.sh) with a fake pipeline."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import RUNS, recording, server, wait_for_tool, wait_job
|
||||
import synth
|
||||
|
||||
|
||||
def convert(name="Kitchen", existing=None, quality="fast", rec=None):
|
||||
rec = rec or recording()
|
||||
e = server.start_job({"path": str(rec), "name": name, "quality": quality, "upload": False,
|
||||
**({"existing": existing} if existing else {})})
|
||||
assert e is None, e
|
||||
return wait_job()
|
||||
|
||||
|
||||
def test_conversion_makes_a_scene():
|
||||
js = convert()
|
||||
assert js["result"] == "ok", js["tail"]
|
||||
assert js["info"]["cameras"] == 20 and js["info"]["rms_cm"] == 1.1
|
||||
s = {x["slug"]: x for x in server.scenes()}["Kitchen"]
|
||||
assert s["name"] == "Kitchen" and s["gaussians"] == 1000 and s["preview"]
|
||||
|
||||
|
||||
def test_a_second_run_of_the_same_name_asks_first():
|
||||
convert()
|
||||
assert server.start_job({"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False}) == "exists"
|
||||
|
||||
|
||||
def test_start_over_that_fails_keeps_the_old_scene():
|
||||
convert(quality="fast")
|
||||
before = (RUNS / "Kitchen" / "scene.sog").stat().st_mtime_ns
|
||||
os.environ["FAKE_PIPELINE"] = "fail"
|
||||
js = convert(existing="fresh", quality="high")
|
||||
assert js["result"] == "failed"
|
||||
assert (RUNS / "Kitchen" / "scene.sog").stat().st_mtime_ns == before # the old result is back
|
||||
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["quality"] == "fast" # with its settings
|
||||
assert not (RUNS / ".previous").exists()
|
||||
|
||||
|
||||
def test_cancelled_start_over_keeps_the_old_scene():
|
||||
convert()
|
||||
before = (RUNS / "Kitchen" / "scene.sog").stat().st_mtime_ns
|
||||
os.environ["FAKE_PIPELINE"] = "slow"
|
||||
assert server.start_job({"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False,
|
||||
"existing": "fresh"}) is None
|
||||
time.sleep(3)
|
||||
# while it computes, the old scene stays in the list, marked
|
||||
assert {s["slug"]: s["replacing"] for s in server.scenes()}.get("Kitchen") is True
|
||||
assert server.cancel_job() is None
|
||||
js = wait_job(timeout=30)
|
||||
assert js["result"] == "cancelled"
|
||||
assert (RUNS / "Kitchen" / "scene.sog").stat().st_mtime_ns == before
|
||||
|
||||
|
||||
def test_start_over_that_works_replaces_the_scene():
|
||||
convert()
|
||||
before = (RUNS / "Kitchen" / "scene.sog").stat().st_mtime_ns
|
||||
time.sleep(0.05)
|
||||
assert convert(existing="fresh")["result"] == "ok"
|
||||
assert (RUNS / "Kitchen" / "scene.sog").stat().st_mtime_ns != before
|
||||
assert not (RUNS / ".previous").exists()
|
||||
|
||||
|
||||
def test_resume_after_an_interrupted_training():
|
||||
rec = recording()
|
||||
(RUNS / "Kitchen" / "dataset").mkdir(parents=True)
|
||||
(RUNS / "Kitchen" / "dataset" / "spirula-dataset.json").write_text('{"status": "complete"}')
|
||||
(RUNS / "Kitchen" / "spirula" / "training" / "step-18000.ckpt").mkdir(parents=True)
|
||||
(RUNS / "Kitchen" / "spirula" / "run.json").write_text('{"status": "running"}')
|
||||
(RUNS / "Kitchen.ui.json").write_text(json.dumps({"name": "Kitchen", "recording": rec.name, "quality": "fast"}))
|
||||
assert server.resume_run("Kitchen", fresh=False) is None
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok", js["tail"]
|
||||
# the (fake) pipeline refuses --resume with an unfinished training, like the real one: it only
|
||||
# worked because splat.sh started the training over
|
||||
assert "Training was interrupted (last checkpoint: 18000.ckpt)" in js["tail"]
|
||||
assert "Incomplete training retained" not in js["tail"]
|
||||
assert not (RUNS / "Kitchen" / "spirula" / "training").exists()
|
||||
|
||||
|
||||
def test_putting_a_tour_together():
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen", recording="2026-10-12_16-40-00_stereo.mp4")
|
||||
synth.make_room(RUNS, "Hallway", "B", name="Hallway", recording="2026-10-12_16-43-00_stereo.mp4", drift=(0.03, 0, 0))
|
||||
assert server.save_group("", "Example home", "Kitchen,Hallway") == "Example-home"
|
||||
assert server.start_combine({"slug": "Example-home", "align": "shift", "partition": "view",
|
||||
"frame_splats": "500000", "full": True}) is None
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok", js["tail"]
|
||||
g = server.groups()["groups"][0]
|
||||
r = g["result"]
|
||||
assert r and r["splats"] > 10000 and r["align"] == "shift" and not r["stale"] and r["preview"]
|
||||
assert r["seams"]["Kitchen|Hallway"]["cm"] < 0.5
|
||||
tour = RUNS / "Example-home"
|
||||
assert (tour / "example-home.sog").exists() and (tour / "example-home.sceneinfo.png").exists()
|
||||
assert (tour / "full" / "example-home-full.sog").exists()
|
||||
# a scene that changes afterwards makes the tour stale
|
||||
time.sleep(0.05)
|
||||
(RUNS / "Kitchen" / "scene.sog").write_bytes(b"newer")
|
||||
assert server.groups()["groups"][0]["result"]["stale"]
|
||||
|
||||
|
||||
def test_a_tour_needs_two_scenes_and_no_other_job():
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
server.save_group("", "Lonely", "Kitchen")
|
||||
assert server.start_combine({"slug": "Lonely"})["code"] == "err.combine_too_few"
|
||||
assert server.start_combine({"slug": "Lonely", "align": "magic"})["code"] == "err.bad_request"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- checks before a conversion starts
|
||||
|
||||
@pytest.mark.parametrize("file,code", [
|
||||
("2026-10-12_16-40-00_mono.mp4", "probe.not_stereo"),
|
||||
("2026-10-12_16-40-00_nogpmd.mp4", "probe.no_gpmd"),
|
||||
("2026-10-12_16-40-00_short.mp4", "probe.too_short"),
|
||||
])
|
||||
def test_unsuitable_recordings_are_refused(api, file, code):
|
||||
rec = recording(file)
|
||||
status, r = api.post("/api/start", {"path": str(rec), "name": "Kitchen", "quality": "fast", "upload": False})
|
||||
assert status == 400 and r["error"]["code"] == code
|
||||
assert not (RUNS / "Kitchen").exists()
|
||||
|
||||
|
||||
def test_a_short_recording_only_warns():
|
||||
p = server.probe(str(recording("2026-10-12_16-40-00_brief.mp4")))
|
||||
assert p["ok"] and p["warnings"][0]["code"] == "probe.short"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,code", [
|
||||
({"path": "/etc/passwd"}, "err.unknown_recording"), # only recordings from the known folders
|
||||
({"quality": "bogus"}, "err.bad_quality"),
|
||||
({"frames": "9999"}, "err.range"),
|
||||
({"scale": "abc"}, "err.bad_value"),
|
||||
({"name": "///"}, "err.bad_name"),
|
||||
])
|
||||
def test_invalid_conversions_are_refused(body, code):
|
||||
full = {"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False, **body}
|
||||
assert server.start_job(full)["code"] == code
|
||||
assert server.load_state().get("job") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- a scene that is being computed is left alone
|
||||
|
||||
def test_a_scene_being_computed_cannot_be_changed(api):
|
||||
convert() # a finished scene ...
|
||||
os.environ["FAKE_PIPELINE"] = "slow" # ... computed again (resume keeps scene.sog meanwhile)
|
||||
assert server.start_job({"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False,
|
||||
"existing": "resume"}) is None
|
||||
time.sleep(2)
|
||||
for path, body in (("/api/delete", {"slug": "Kitchen"}), ("/api/rename", {"slug": "Kitchen", "new": "Other"}),
|
||||
("/api/clean", {"slug": "Kitchen", "what": "temp"})):
|
||||
status, r = api.post(path, body)
|
||||
assert status == 400 and r["error"]["code"] == "err.scene_busy", path
|
||||
# and no tour is put together meanwhile (one job at a time)
|
||||
synth.make_room(RUNS, "Office", "A")
|
||||
synth.make_room(RUNS, "Hallway", "B")
|
||||
assert server.save_group("", "Home", "Office,Hallway") == "Home"
|
||||
assert server.start_combine({"slug": "Home"})["code"] == "err.job_busy"
|
||||
assert (RUNS / "Kitchen" / "scene.sog").exists() and (RUNS / "Kitchen.log").exists()
|
||||
server.cancel_job()
|
||||
wait_job(timeout=30)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- after a power cut
|
||||
|
||||
def leftover_from_a_power_cut():
|
||||
"""A start over that never finished: the old scene sits in .previous, a half new run in its place."""
|
||||
convert(quality="fast")
|
||||
old = (RUNS / "Kitchen" / "scene.sog").read_bytes()
|
||||
prev = RUNS / ".previous" / "Kitchen"
|
||||
prev.mkdir(parents=True)
|
||||
(RUNS / "Kitchen").rename(prev / "run")
|
||||
(RUNS / "Kitchen.log").rename(prev / "log")
|
||||
(RUNS / "Kitchen").mkdir()
|
||||
(RUNS / "Kitchen" / "half.txt").write_text("half")
|
||||
meta = json.loads((RUNS / "Kitchen.ui.json").read_text())
|
||||
(RUNS / "Kitchen.ui.json").write_text(json.dumps({**meta, "quality": "high", "previous": meta}))
|
||||
return old
|
||||
|
||||
|
||||
def test_the_server_puts_back_a_scene_left_after_a_power_cut():
|
||||
old = leftover_from_a_power_cut()
|
||||
server.restore_previous()
|
||||
assert (RUNS / "Kitchen" / "scene.sog").read_bytes() == old
|
||||
assert not (RUNS / "Kitchen" / "half.txt").exists()
|
||||
assert (RUNS / "Kitchen.log").exists()
|
||||
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["quality"] == "fast"
|
||||
assert not (RUNS / ".previous").exists()
|
||||
|
||||
|
||||
def test_start_over_after_a_power_cut_keeps_the_old_scene_if_it_fails():
|
||||
old = leftover_from_a_power_cut()
|
||||
os.environ["FAKE_PIPELINE"] = "fail"
|
||||
js = convert(existing="fresh")
|
||||
assert js["result"] == "failed"
|
||||
assert (RUNS / "Kitchen" / "scene.sog").read_bytes() == old # splat.sh first put the left-over back
|
||||
|
||||
|
||||
def test_start_over_from_the_list_of_cancelled_runs():
|
||||
rec = recording()
|
||||
# stopped while extracting images: what the pipeline leaves behind (state "running", half the images)
|
||||
(RUNS / "Kitchen" / "dataset" / "images" / "cam0").mkdir(parents=True)
|
||||
(RUNS / "Kitchen" / "dataset" / "images" / "cam0" / "0000.jpg").write_bytes(b"x")
|
||||
(RUNS / "Kitchen" / "dataset" / "spirula-dataset.json").write_text('{"status": "running"}')
|
||||
(RUNS / "Kitchen.log").write_text("Extraction progress: decoded 4% / saved 44 of 1000\n")
|
||||
(RUNS / "Kitchen.ui.json").write_text(json.dumps({"name": "Kitchen", "recording": rec.name, "quality": "schnell"}))
|
||||
assert server.resume_run("Kitchen", fresh=False)["code"] == "err.not_resumable"
|
||||
assert server.resume_run("Kitchen", fresh=True) is None
|
||||
js = wait_job()
|
||||
assert js["result"] == "ok" and js["quality"] == "fast" # settings of the first version understood
|
||||
assert "Kitchen" in {s["slug"] for s in server.scenes()}
|
||||
assert "Kitchen" not in {u["slug"] for u in server.unfinished_runs()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- after a job: the watcher
|
||||
|
||||
def test_after_a_job_the_watcher_notifies_and_tidies_up():
|
||||
convert()
|
||||
os.environ["FAKE_PIPELINE"] = "ok"
|
||||
assert server.start_job({"path": str(recording()), "name": "Kitchen", "quality": "fast", "upload": False,
|
||||
"existing": "fresh"}) is None
|
||||
assert server.watch_once(None) is True # running
|
||||
wait_job()
|
||||
assert "previous" in json.loads((RUNS / "Kitchen.ui.json").read_text())
|
||||
assert server.watch_once(True) is False # it has just ended
|
||||
assert "previous" not in json.loads((RUNS / "Kitchen.ui.json").read_text())
|
||||
notes = wait_for_tool("notify-send")
|
||||
assert notes and "Kitchen" in notes[-1][-1]
|
||||
|
||||
|
||||
def test_after_a_failed_job_the_watcher_says_so():
|
||||
os.environ["FAKE_PIPELINE"] = "fail"
|
||||
convert()
|
||||
server.watch_once(True)
|
||||
assert wait_for_tool("notify-send")
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Recordings on the PC and on the Frame: renaming, the Frame's list, download, copying scenes over."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from conftest import REC, RUNS, recording, server, wait_for_tool
|
||||
import synth
|
||||
|
||||
VIDEOS = server.FRAME_VIDEOS
|
||||
SCENES = server.FRAME_SCENES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- renaming a recording on the PC
|
||||
|
||||
def test_renaming_a_recording_keeps_date_check_result_and_thumbnail(api):
|
||||
rec = recording("2026-10-12_16-40-00_stereo.mp4")
|
||||
server.CACHE.mkdir(parents=True, exist_ok=True)
|
||||
check = server.check_file(rec)
|
||||
check.write_text(json.dumps({"level": "warn"}))
|
||||
(server.CACHE / "2026-10-12_16-40-00_stereo.thumb").write_bytes(b"thumb")
|
||||
status, r = api.post("/api/rename", {"name": rec.name, "new": "Kitchen / evening"})
|
||||
assert status == 200 and r["name"] == "Kitchen - evening.mp4"
|
||||
new = REC / "Kitchen - evening.mp4"
|
||||
assert new.exists() and not rec.exists()
|
||||
f = next(x for x in server.local_files() if x["name"] == new.name)
|
||||
assert (f["date"], f["label"], f["orig"]) == ("2026-10-12T16:40", "Kitchen - evening", rec.name)
|
||||
assert f["check"] == "warn" # the check result moved along
|
||||
assert (server.CACHE / "Kitchen - evening.thumb").read_bytes() == b"thumb"
|
||||
|
||||
|
||||
def test_runs_follow_a_renamed_recording(api):
|
||||
rec = recording()
|
||||
synth.make_room(RUNS, "Kitchen", "A", recording=rec.name) # a finished scene made from it
|
||||
api.post("/api/rename", {"name": rec.name, "new": "Kitchen take 1"})
|
||||
meta = json.loads((RUNS / "Kitchen.ui.json").read_text())
|
||||
assert meta["recording"] == "Kitchen take 1.mp4" and meta["recording_renamed"] is True
|
||||
assert server.no_resume("Kitchen") == "renamed"
|
||||
|
||||
|
||||
def test_a_recording_with_an_unfinished_run_cannot_be_renamed(api):
|
||||
rec = recording()
|
||||
(RUNS / "Kitchen").mkdir() # interrupted, no scene.sog
|
||||
(RUNS / "Kitchen.ui.json").write_text(json.dumps({"name": "Kitchen", "recording": rec.name}))
|
||||
status, r = api.post("/api/rename", {"name": rec.name, "new": "Other"})
|
||||
assert status == 400 and r["error"]["code"] == "err.recording_unfinished" # the pipeline could not resume it
|
||||
assert rec.exists()
|
||||
|
||||
|
||||
def test_renaming_a_recording_to_a_taken_name_is_refused(api):
|
||||
a, b = recording("2026-10-12_16-40-00_stereo.mp4"), recording("2026-10-12_16-43-00_stereo.mp4")
|
||||
status, r = api.post("/api/rename", {"name": a.name, "new": b.stem})
|
||||
assert status == 400 and r["error"]["code"] == "err.name_taken"
|
||||
assert api.post("/api/rename", {"name": "../../etc/passwd", "new": "x"})[0] == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the Frame
|
||||
|
||||
def put_on_frame(home, name="2026-10-14_10-00-00_stereo.mp4", data=b"recording" * 1000):
|
||||
(home / VIDEOS / name).write_bytes(data)
|
||||
(home / VIDEOS / "thumbnails" / name.replace("_stereo.mp4", ".jpg")).write_bytes(b"jpegdata")
|
||||
return name, data
|
||||
|
||||
|
||||
def test_the_frames_recordings_are_listed(api, frame):
|
||||
name, data = put_on_frame(frame)
|
||||
(frame / SCENES / "20_Kitchen").mkdir()
|
||||
status, r = api.get("/api/recordings?refresh=1")
|
||||
assert status == 200 and r["frame_online"]
|
||||
f = r["frame"][0]
|
||||
assert (f["name"], f["size"], f["date"], f["local"]) == (name, len(data), "2026-10-14T10:00", None)
|
||||
assert server.frame_listing()["scenes"] == ["20_Kitchen"]
|
||||
|
||||
|
||||
def test_a_frame_that_is_off(api, frame):
|
||||
os.environ["FAKE_FRAME_OFF"] = "1"
|
||||
r = api.get("/api/recordings?refresh=1")[1]
|
||||
assert r["frame_configured"] and r["frame_online"] is False and r["frame"] is None
|
||||
|
||||
|
||||
def test_the_frames_preview_image(api, frame):
|
||||
name, _ = put_on_frame(frame)
|
||||
status, body = api.get(f"/api/thumb?source=frame&name={name}")
|
||||
assert status == 200 and body == b"jpegdata"
|
||||
|
||||
|
||||
def test_downloading_a_recording(api, frame):
|
||||
name, data = put_on_frame(frame)
|
||||
server.frame_listing(force=True)
|
||||
assert api.post("/api/download", {"name": name})[0] == 200
|
||||
end = time.time() + 20
|
||||
while not (server.download or {}).get("finished") and time.time() < end:
|
||||
time.sleep(0.1)
|
||||
assert server.download["error"] is None
|
||||
assert (REC / name).read_bytes() == data and not (REC / (name + ".part")).exists()
|
||||
status, r = api.post("/api/download", {"name": name}) # never overwrites
|
||||
assert status == 400 and r["error"]["code"] == "err.name_taken"
|
||||
assert api.post("/api/download", {"name": "not-there.mp4"})[1]["error"]["code"] == "err.not_on_frame"
|
||||
|
||||
|
||||
def wait_upload(slug):
|
||||
end = time.time() + 120
|
||||
while time.time() < end:
|
||||
u = server.upload_status(slug)
|
||||
if u and not u["running"]:
|
||||
return u
|
||||
time.sleep(0.2)
|
||||
raise TimeoutError
|
||||
|
||||
|
||||
def test_copying_a_scene_to_the_frame(api, frame):
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
assert api.post("/api/upload", {"slug": "Kitchen"})[0] == 200
|
||||
u = wait_upload("Kitchen")
|
||||
assert u["ok"], u.get("error")
|
||||
folder = frame / SCENES / "20_Kitchen"
|
||||
assert sorted(p.name for p in folder.iterdir()) == [
|
||||
"kitchen.gallery.png", "kitchen.sceneinfo.png", "kitchen.sog", "kitchen.xrSceneTransform.json"]
|
||||
assert (frame / "Documents" / "GaussianSplats" / "20_Kitchen" / "kitchen.sog").exists() # backup copy
|
||||
assert json.loads((RUNS / "Kitchen.ui.json").read_text())["frame_folder"] == "20_Kitchen"
|
||||
assert wait_for_tool("notify-send") # sent in the background
|
||||
assert {s["slug"]: s["on_frame"] for s in server.scenes()}["Kitchen"] is True
|
||||
|
||||
|
||||
def test_a_renamed_scene_stays_in_its_frame_folder(api, frame):
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
api.post("/api/upload", {"slug": "Kitchen"})
|
||||
wait_upload("Kitchen")
|
||||
api.post("/api/rename", {"slug": "Kitchen", "new": "Küche"})
|
||||
assert api.post("/api/upload", {"slug": "Kueche"})[0] == 200
|
||||
assert wait_upload("Kueche")["ok"]
|
||||
scenes = sorted(p.name for p in (frame / SCENES).iterdir())
|
||||
assert scenes == ["20_Kitchen"] # no second copy
|
||||
assert sorted(p.name for p in (frame / SCENES / "20_Kitchen").iterdir()) == [
|
||||
"kueche.gallery.png", "kueche.sceneinfo.png", "kueche.sog", "kueche.xrSceneTransform.json"]
|
||||
|
||||
|
||||
def test_a_broken_download_leaves_nothing_behind(api, frame):
|
||||
name, data = put_on_frame(frame)
|
||||
server.frame_listing(force=True)
|
||||
os.environ["FAKE_FRAME_CUT"] = "1" # the connection breaks halfway
|
||||
assert api.post("/api/download", {"name": name})[0] == 200
|
||||
end = time.time() + 20
|
||||
while not (server.download or {}).get("finished") and time.time() < end:
|
||||
time.sleep(0.1)
|
||||
assert server.download["error"]
|
||||
assert not (REC / name).exists() and not (REC / (name + ".part")).exists()
|
||||
|
||||
|
||||
def test_a_scene_renamed_before_its_frame_folder_was_remembered(api, frame):
|
||||
"""Copied to the Frame by an older version (no frame_folder in its settings): renaming finds the
|
||||
folder on the Frame, so copying again does not create a second one."""
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
(frame / SCENES / "20_Kitchen").mkdir()
|
||||
(frame / SCENES / "20_Kitchen" / "kitchen.sog").write_bytes(b"old")
|
||||
server.frame_listing(force=True)
|
||||
assert api.post("/api/rename", {"slug": "Kitchen", "new": "Küche"})[0] == 200
|
||||
assert json.loads((RUNS / "Kueche.ui.json").read_text())["frame_folder"] == "20_Kitchen"
|
||||
api.post("/api/upload", {"slug": "Kueche"})
|
||||
assert wait_upload("Kueche")["ok"]
|
||||
assert sorted(p.name for p in (frame / SCENES).iterdir()) == ["20_Kitchen"]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Finished scenes: renaming, cleaning up, deleting, and runs that did not finish."""
|
||||
import json
|
||||
|
||||
from conftest import RUNS, TRASH, server
|
||||
import synth
|
||||
|
||||
|
||||
def scene_names():
|
||||
return {s["slug"]: s["name"] for s in server.scenes()}
|
||||
|
||||
|
||||
def test_finished_scenes_are_listed(api):
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen")
|
||||
status, scenes = api.get("/api/scenes")
|
||||
assert status == 200
|
||||
assert [(s["slug"], s["name"], s["ply"]) for s in scenes] == [("Kitchen", "Kitchen", True)]
|
||||
assert scenes[0]["gaussians"] > 1000
|
||||
|
||||
|
||||
def test_renaming_moves_folder_files_and_log(api):
|
||||
run = synth.make_room(RUNS, "Aufnahme-12.10.-16-40", "A", name="Aufnahme 12.10.")
|
||||
(run / "aufnahme-12.10.-16-40.sog").write_bytes(b"x") # gallery files are named after the folder
|
||||
(run / "aufnahme-12.10.-16-40.gallery.png").write_bytes(b"x")
|
||||
status, r = api.post("/api/rename", {"slug": "Aufnahme-12.10.-16-40", "new": "Büro 12.10."})
|
||||
assert status == 200, r
|
||||
new = RUNS / "Buero-12.10."
|
||||
assert new.is_dir() and not run.exists()
|
||||
assert (new / "buero-12.10..sog").exists() and (new / "buero-12.10..gallery.png").exists()
|
||||
assert (RUNS / "Buero-12.10..log").exists() and not (RUNS / "Aufnahme-12.10.-16-40.log").exists()
|
||||
meta = json.loads((RUNS / "Buero-12.10..ui.json").read_text())
|
||||
assert meta["name"] == "Büro 12.10." and meta["folder_renamed"] is True
|
||||
assert server.no_resume("Buero-12.10.") == "renamed" # the pipeline remembers the old path
|
||||
|
||||
|
||||
def test_renaming_to_a_taken_name_is_refused(api):
|
||||
synth.make_room(RUNS, "Kitchen", "A")
|
||||
synth.make_room(RUNS, "Office", "B")
|
||||
status, r = api.post("/api/rename", {"slug": "Office", "new": "Kitchen"})
|
||||
assert status == 400 and r["error"]["code"] == "err.scene_name_taken"
|
||||
assert (RUNS / "Office").is_dir()
|
||||
|
||||
|
||||
def test_cleaning_up_removes_only_what_it_says(api):
|
||||
run = synth.make_room(RUNS, "Kitchen", "A")
|
||||
for rel in ("spirula/training/step-1.ckpt", "dataset/.spirula-sfm/x", "dataset/images/cam0/0000.jpg"):
|
||||
(run / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(run / rel).write_bytes(b"x" * 1000)
|
||||
assert api.post("/api/clean", {"slug": "Kitchen", "what": "temp"})[0] == 200
|
||||
assert not (run / "spirula/training").exists() and not (run / "dataset/.spirula-sfm").exists()
|
||||
assert (run / "dataset/images/cam0/0000.jpg").exists() # training images stay
|
||||
assert (run / "spirula/scene.ply").exists() and (run / "dataset/sparse/0/images.bin").exists()
|
||||
assert server.no_resume("Kitchen") is None
|
||||
assert api.post("/api/clean", {"slug": "Kitchen", "what": "images"})[0] == 200
|
||||
assert not (run / "dataset/images").exists()
|
||||
assert server.no_resume("Kitchen") == "images" # only a fresh start from now on
|
||||
assert api.post("/api/clean", {"slug": "Kitchen", "what": "everything"})[0] == 400
|
||||
|
||||
|
||||
def test_deleting_moves_to_the_trash(api):
|
||||
synth.make_room(RUNS, "Kitchen", "A")
|
||||
assert api.post("/api/delete", {"slug": "Kitchen"})[0] == 200
|
||||
assert "Kitchen" not in scene_names()
|
||||
assert {p.name for p in TRASH.iterdir()} >= {"Kitchen", "Kitchen.log", "Kitchen.ui.json"}
|
||||
|
||||
|
||||
def test_unfinished_runs_resume_or_start_over(api):
|
||||
# interrupted in the training: camera positions done -> it can continue
|
||||
(RUNS / "Hallway" / "dataset").mkdir(parents=True)
|
||||
(RUNS / "Hallway" / "dataset" / "spirula-dataset.json").write_text('{"status": "complete"}')
|
||||
(RUNS / "Hallway.log").write_text("Running native Spirula SfM\nstep 13801/30000 ( 46%) splats 2000000 "
|
||||
"[elapsed 00:10:00.0 | ETA 00:12:00.0] rgb_loss=0.1 ssim=0.7 psnr=25\n")
|
||||
rec = "2026-10-12_16-43-00_stereo.mp4"
|
||||
(RUNS / "Hallway.ui.json").write_text(json.dumps({"name": "Hallway", "recording": rec}))
|
||||
(server.REC / rec).write_bytes(b"x")
|
||||
# stopped while extracting images -> only from the start
|
||||
(RUNS / "Broken").mkdir()
|
||||
(RUNS / "Broken.log").write_text("Extraction progress: decoded 4% / saved 44 of 1000\n")
|
||||
(RUNS / "Broken.ui.json").write_text(json.dumps({"name": "Broken", "recording": rec}))
|
||||
(RUNS / "old-test.log").write_text("something\n") # only a log file left
|
||||
synth.make_room(RUNS, "Kitchen", "A", name="Kitchen") # finished: not in this list
|
||||
|
||||
status, runs = api.get("/api/unfinished")
|
||||
assert status == 200
|
||||
by = {r["slug"]: r for r in runs}
|
||||
assert set(by) == {"Hallway", "Broken", "old-test"}
|
||||
assert (by["Hallway"]["phase"], by["Hallway"]["pct"], by["Hallway"]["resumable"]) == (2, 46, True)
|
||||
assert (by["Broken"]["phase"], by["Broken"]["resumable"], by["Broken"]["restartable"]) == (0, False, True)
|
||||
assert by["old-test"]["phase"] is None and not by["old-test"]["restartable"]
|
||||
assert api.post("/api/resume", {"slug": "Broken"})[1]["error"]["code"] == "err.not_resumable"
|
||||
assert api.post("/api/delete", {"slug": "old-test"})[0] == 200
|
||||
assert not (RUNS / "old-test.log").exists()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Who may talk to the server: only this PC (or the home network in LAN mode), never other websites."""
|
||||
import pytest
|
||||
|
||||
from conftest import RUNS, SHUTDOWNS, server
|
||||
import synth
|
||||
|
||||
|
||||
def test_page_and_api_answer_locally(api):
|
||||
assert api.get("/")[0] == 200
|
||||
status, ping = api.get("/api/ping")
|
||||
assert status == 200 and ping["app"] == "splat-workshop" and ping["version"] == server.VERSION
|
||||
|
||||
|
||||
@pytest.mark.parametrize("site", ["cross-site", "same-site"])
|
||||
def test_other_websites_are_refused(api, site):
|
||||
# e.g. <img src="http://127.0.0.1:8771/api/floorplan?..."> on any website, or another port on localhost
|
||||
assert api.get("/api/scenes", {"Sec-Fetch-Site": site})[0] == 403
|
||||
assert api.post("/api/cancel", {}, {"Sec-Fetch-Site": site})[0] == 403
|
||||
|
||||
|
||||
def test_own_page_and_typed_address_work(api):
|
||||
assert api.get("/api/scenes", {"Sec-Fetch-Site": "same-origin"})[0] == 200
|
||||
assert api.get("/api/scenes", {"Sec-Fetch-Site": "none"})[0] == 200
|
||||
|
||||
|
||||
def test_a_link_may_open_the_page_but_not_the_api(api):
|
||||
nav = {"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "navigate"}
|
||||
assert api.get("/", nav)[0] == 200
|
||||
assert api.get("/api/scenes", nav)[0] == 403
|
||||
|
||||
|
||||
def test_post_from_another_origin_is_refused(api):
|
||||
assert api.post("/api/cancel", {}, {"Origin": "http://evil.example"})[0] == 403
|
||||
|
||||
|
||||
def test_dns_rebinding_is_refused(api):
|
||||
# a foreign host name that resolves to 127.0.0.1
|
||||
assert api.get("/api/scenes", {"Host": "evil.example:8771"})[0] == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lan,client,ok", [
|
||||
(False, "127.0.0.1", True), (False, "192.168.1.20", False),
|
||||
(True, "192.168.1.20", True), (True, "10.8.0.2", True),
|
||||
(True, "100.100.1.1", False), # carrier-grade NAT / Tailscale: not a home network
|
||||
(True, "84.150.3.3", False), # the internet, e.g. through a port forwarding
|
||||
])
|
||||
def test_lan_mode_checks_the_real_client_address(monkeypatch, lan, client, ok):
|
||||
monkeypatch.setattr(server.CFG, "lan", lan)
|
||||
|
||||
class H(server.Handler):
|
||||
def __init__(self): # no socket: only what _host_ok looks at
|
||||
self.client_address = (client, 50000)
|
||||
self.headers = {"Host": "192.168.1.5:8771" if lan else "127.0.0.1:8771"}
|
||||
self.command, self.path = "GET", "/api/scenes"
|
||||
assert H()._host_ok() is ok
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,headers", [
|
||||
(b"[1, 2]", {}), # a list instead of an object
|
||||
(b'{"name": 5, "new": "x"}', {}), # a number instead of text
|
||||
(b'{"slug": {"a": 1}}', {}), # nested objects
|
||||
(b"{not json", {}),
|
||||
(b"", {"Content-Length": "-5"}),
|
||||
(b"", {"Content-Length": str(10 ** 9)}),
|
||||
(b'{"slug": "x", "full": {"a": 1}}', {}), # nested value in a field that is not text
|
||||
])
|
||||
def test_invalid_requests_get_400_not_a_crash(api, body, headers):
|
||||
assert api.post("/api/rename", body, headers)[0] == 400
|
||||
assert api.get("/api/ping")[0] == 200 # still alive
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def leak():
|
||||
"""Something that looks like a scene one level above the runs folder – it must never be reachable."""
|
||||
above = RUNS.parent
|
||||
(above / "spirula").mkdir(exist_ok=True)
|
||||
(above / "spirula" / "scene.ply").write_bytes(b"ply\n")
|
||||
(above / "leak.gallery.png").write_bytes(b"secret")
|
||||
(above / "scene.sog").write_bytes(b"secret")
|
||||
yield
|
||||
for f in ("leak.gallery.png", "scene.sog"):
|
||||
(above / f).unlink(missing_ok=True)
|
||||
(above / "spirula" / "scene.ply").unlink(missing_ok=True)
|
||||
(above / "spirula").rmdir()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", ["..", "../runs", "/etc", ".previous", "a/../../b", "."])
|
||||
def test_no_paths_outside_the_runs_folder(api, leak, slug):
|
||||
synth.make_room(RUNS, "Room", "A")
|
||||
assert server.scene_dir(slug) is None
|
||||
status, body = api.get(f"/api/preview?slug={slug}")
|
||||
assert status == 404 and body != b"secret"
|
||||
assert api.get(f"/api/floorplan?slugs={slug}")[0] == 400
|
||||
for path, body in (("/api/delete", {"slug": slug}), ("/api/clean", {"slug": slug, "what": "temp"}),
|
||||
("/api/rename", {"slug": slug, "new": "x"}), ("/api/upload", {"slug": slug}),
|
||||
("/api/combine", {"slug": slug})):
|
||||
assert api.post(path, body)[0] == 400, path
|
||||
assert (RUNS / "Room" / "scene.sog").exists() and (RUNS.parent / "leak.gallery.png").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,body", [
|
||||
("/api/open", {"slug": "Room", "what": "folder"}), ("/api/play", {"name": "x.mp4"}),
|
||||
("/api/pick", {}), ("/api/shutdown", {}),
|
||||
])
|
||||
def test_actions_on_the_screen_only_from_this_pc(api, monkeypatch, path, body):
|
||||
# video player, file dialog, folder, Spirula and quitting: not from another device in the LAN
|
||||
monkeypatch.setattr(server.Handler, "_from_this_pc", lambda self: False)
|
||||
started = []
|
||||
monkeypatch.setattr(server.subprocess, "Popen", lambda *a, **k: started.append(a))
|
||||
monkeypatch.setattr(server, "pick_file", lambda: started.append("file dialog"))
|
||||
synth.make_room(RUNS, "Room", "A")
|
||||
status, r = api.post(path, body)
|
||||
assert status == 403 and r["error"]["code"] == "err.pc_only"
|
||||
assert started == [] and SHUTDOWNS == [] # and nothing happened
|
||||
|
||||
|
||||
def test_from_this_pc_means_loopback():
|
||||
class Remote(server.Handler):
|
||||
def __init__(self):
|
||||
self.client_address = ("192.168.1.20", 50000)
|
||||
|
||||
class Local(server.Handler):
|
||||
def __init__(self):
|
||||
self.client_address = ("127.0.0.1", 50000)
|
||||
assert not Remote()._from_this_pc() and Local()._from_this_pc()
|
||||
|
||||
|
||||
ENDPOINTS = ["/api/start", "/api/rename", "/api/clean", "/api/delete", "/api/upload", "/api/download", "/api/check",
|
||||
"/api/group", "/api/group/dismiss", "/api/combine", "/api/resume"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ENDPOINTS)
|
||||
@pytest.mark.parametrize("field", ["name", "new", "slug", "path", "what", "members", "key", "align", "quality"])
|
||||
def test_wrongly_typed_fields_get_400_everywhere(api, path, field):
|
||||
for bad in (5, ["a"], True):
|
||||
assert api.post(path, {field: bad})[0] == 400, (path, field, bad)
|
||||
|
||||
|
||||
def test_unknown_thumbnail_names_leave_no_cache_files(api):
|
||||
status, _ = api.get("/api/thumb?source=frame&name=whatever.mp4")
|
||||
assert status == 404
|
||||
assert not list(server.CACHE.glob("whatever*"))
|
||||
|
||||
|
||||
def test_nested_values_are_refused_in_any_field(api):
|
||||
# a request that would work, apart from one nested value in a field that is not text
|
||||
synth.make_room(RUNS, "Room", "A")
|
||||
assert api.post("/api/rename", {"slug": "Room", "new": "Other", "update_frame": {"a": 1}})[0] == 400
|
||||
assert (RUNS / "Room").is_dir() and not (RUNS / "Other").exists()
|
||||
+160
-2
@@ -101,10 +101,18 @@ const I18N = {
|
||||
"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 +121,78 @@ 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",
|
||||
"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.rooms": "{n} scenes", "grp.show": "▸ Show {n} scenes", "grp.hide": "▾ Hide scenes",
|
||||
"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.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}",
|
||||
"err.in_other_group": "“{group}” already contains one of these scenes",
|
||||
"err.combine_too_few": "Putting together needs at least two finished scenes",
|
||||
"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",
|
||||
@@ -216,9 +295,17 @@ const I18N = {
|
||||
"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 +314,77 @@ 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",
|
||||
"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.rooms": "{n} Szenen", "grp.show": "▸ {n} Szenen zeigen", "grp.hide": "▾ Szenen ausblenden",
|
||||
"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": "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}",
|
||||
"err.in_other_group": "„{group}“ enthält schon eine dieser Szenen",
|
||||
"err.combine_too_few": "Zum Zusammensetzen braucht es mindestens zwei fertige Szenen",
|
||||
"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",
|
||||
|
||||
+502
-23
@@ -8,7 +8,7 @@
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1115; --panel: #171a21; --panel2: #1e222b; --line: #2a2f3a;
|
||||
--text: #e6e8ee; --muted: #9aa1b1; --faint: #6b7283;
|
||||
--text: #e6e8ee; --muted: #9aa1b1; --faint: #848b9c;
|
||||
--accent: #7c9cff; --accent2: #46d3b5; --warn: #ffb86b; --bad: #ff6b81; --ok: #46d3b5;
|
||||
--radius: 14px;
|
||||
}
|
||||
@@ -86,7 +86,7 @@ label.field small { font-size: 12px; color: var(--faint); line-height: 1.4; }
|
||||
.finding b { display: block; font-size: 13.5px; }
|
||||
.finding.bad b { color: var(--bad); } .finding.warn b { color: var(--warn); } .finding.info b { color: var(--muted); }
|
||||
.cres .note { margin-top: 12px; font-size: 12px; color: var(--faint); }
|
||||
.rec .meta b { font-size: 14px; font-weight: 600; }
|
||||
.rec .meta b { font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.rec .meta span { font-size: 12.5px; color: var(--muted); }
|
||||
.badge { font-size: 11px; padding: 2px 7px; border-radius: 99px; background: color-mix(in srgb, var(--accent2) 18%, transparent); color: var(--accent2); }
|
||||
.empty { padding: 26px; text-align: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 12px; }
|
||||
@@ -155,7 +155,50 @@ dialog::backdrop { background: rgba(0,0,0,.55); }
|
||||
padding: 10px 16px; border-radius: 10px; font-size: 14px; opacity: 0; transition: opacity .2s; pointer-events: none; z-index: 10; }
|
||||
.toast.show { opacity: 1; }
|
||||
.hidden { display: none !important; }
|
||||
.group { background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin-bottom: 12px; }
|
||||
.ghead { display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
.gthumb { width: 132px; height: 132px; flex: none; border-radius: 10px; background: #0b0d12 center/cover no-repeat; display: grid; place-items: center; font-size: 44px; }
|
||||
.gmeta { flex: 1; min-width: 220px; display: grid; gap: 3px; align-content: start; }
|
||||
.gmeta b { font-size: 15px; overflow-wrap: anywhere; } .gmeta span { font-size: 12.5px; color: var(--muted); }
|
||||
.gmeta .warn { color: var(--warn); }
|
||||
.group .acts { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.group .ginner { margin-top: 12px; }
|
||||
.suggest { border: 1px dashed var(--accent); border-radius: 12px; padding: 12px 14px; margin-bottom: 12px; font-size: 13.5px; }
|
||||
.suggest .row { margin-top: 8px; }
|
||||
dialog.wide { max-width: 560px; }
|
||||
dialog select { background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; font: inherit; }
|
||||
dialog h3 { font-size: 13.5px; margin: 16px 0 8px; }
|
||||
.opts { display: grid; gap: 6px; }
|
||||
.unfin { display: grid; gap: 8px; }
|
||||
.unfin .row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.unfin .row b { overflow-wrap: anywhere; } .unfin .row .sp { flex: 1; }
|
||||
.offline-note { border-color: var(--bad); }
|
||||
body.offline main > :not(#offline), body.offline #framePill { opacity: .45; pointer-events: none; }
|
||||
body.remote .pc-only { display: none !important; }
|
||||
/* floor plan */
|
||||
dialog.plan { max-width: min(1200px, 94vw); width: 94vw; }
|
||||
.plan-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.plan-head h2 { margin: 0; flex: 1; }
|
||||
.btnlink { display: inline-block; text-decoration: none; color: var(--text); background: var(--panel2); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: 5px 10px; font-size: 13px; }
|
||||
.btnlink:hover { border-color: var(--accent); }
|
||||
.seg2 { display: inline-flex; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 3px; gap: 3px; }
|
||||
.seg2 button { border: 0; background: transparent; padding: 5px 12px; font-size: 13px; color: var(--muted); }
|
||||
.seg2 button.active { background: var(--accent); color: #0b0d12; font-weight: 600; }
|
||||
.plan-img { position: relative; background: #0f1115; border-radius: 12px; min-height: 260px; display: grid; place-items: center; overflow: hidden; }
|
||||
.plan-stage { position: relative; display: inline-block; line-height: 0; }
|
||||
.plan-img img { max-width: 100%; max-height: 74vh; width: auto; height: auto; display: block; }
|
||||
.plan-stage svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.plan-stage.measuring svg { pointer-events: auto; cursor: crosshair; }
|
||||
.measure-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: -2px 0 10px; font-size: 13.5px; }
|
||||
.measure-bar .total { font-weight: 650; font-variant-numeric: tabular-nums; background: color-mix(in srgb, #ffd166 18%, var(--panel2));
|
||||
color: #ffd166; padding: 4px 10px; border-radius: 99px; }
|
||||
.seg2 button.on, #planMeasure.on { background: #ffd166; color: #0b0d12; border-color: #ffd166; font-weight: 600; }
|
||||
.plan-img .wait { color: var(--muted); font-size: 14px; padding: 30px; text-align: center; }
|
||||
.pick { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 6px; margin: 10px 0 14px; }
|
||||
.pick label { display: flex; gap: 8px; align-items: center; padding: 8px 10px; background: var(--panel2); border-radius: 10px; font-size: 14px; cursor: pointer; }
|
||||
.pick label span { color: var(--faint); font-size: 12px; margin-left: auto; }
|
||||
.pick input { accent-color: var(--accent); }
|
||||
#lanPill { cursor: pointer; } #lanPill:hover { color: var(--text); }
|
||||
.setup { border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
|
||||
.setup h2 { color: var(--warn); }
|
||||
@@ -176,6 +219,7 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="card offline-note hidden" id="offline" role="alert"><b data-i18n="offline.title"></b> <span class="muted" data-i18n="offline.text"></span></div>
|
||||
<!-- missing prerequisites -->
|
||||
<section class="card setup hidden" id="setupCard">
|
||||
<h2 data-i18n="setup.title"></h2>
|
||||
@@ -272,9 +316,21 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
|
||||
<!-- finished scenes -->
|
||||
<section class="card">
|
||||
<h2 data-i18n="scenes.title"></h2>
|
||||
<div class="row" style="justify-content:space-between;margin-bottom:14px">
|
||||
<h2 data-i18n="scenes.title" style="margin:0"></h2>
|
||||
<div class="row"><button class="small" id="groupNewBtn" data-i18n="grp.new" data-i18n-title="grp.new.tip"></button></div>
|
||||
</div>
|
||||
<div id="groupSuggest" class="suggest hidden"></div>
|
||||
<div id="groupList"></div>
|
||||
<div id="sceneGrid" class="grid"></div>
|
||||
</section>
|
||||
|
||||
<!-- cancelled / failed runs -->
|
||||
<section class="card hidden" id="unfinCard">
|
||||
<h2 data-i18n="unfin.title" style="margin:0 0 4px"></h2>
|
||||
<p class="faint" style="font-size:12.5px;margin:0 0 12px" data-i18n="unfin.hint"></p>
|
||||
<div id="unfinList" class="unfin"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="existsDlg">
|
||||
@@ -286,6 +342,77 @@ select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background:
|
||||
<button class="primary" id="exFresh" data-i18n="exists.fresh"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="groupDlg">
|
||||
<h2 style="margin-top:0" id="groupTitle"></h2>
|
||||
<label class="field"><span data-i18n="grp.name"></span><input id="groupName" type="text" maxlength="80" style="width:100%" autocomplete="off"></label>
|
||||
<p class="muted" style="font-size:13px;margin:12px 0 6px" data-i18n="grp.pick"></p>
|
||||
<div id="groupPick" class="pick"></div>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:16px">
|
||||
<button id="groupCancel" data-i18n="grp.cancel"></button>
|
||||
<button class="primary" id="groupSave" data-i18n="grp.save"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="combineDlg" class="wide">
|
||||
<h2 style="margin-top:0" id="cmbTitle"></h2>
|
||||
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="cmb.hint"></p>
|
||||
<h3 data-i18n="cmb.align"></h3>
|
||||
<div class="opts" id="cmbAlign"></div>
|
||||
<h3 data-i18n="cmb.part"></h3>
|
||||
<div class="opts" id="cmbPart"></div>
|
||||
<h3 data-i18n="cmb.size"></h3>
|
||||
<select id="cmbSize"><option value="2000000" data-i18n="cmb.size.2"></option><option value="3500000" data-i18n="cmb.size.35"></option><option value="5000000" data-i18n="cmb.size.5"></option></select>
|
||||
<label class="row" style="margin-top:14px;gap:8px;font-size:13.5px"><input type="checkbox" id="cmbFull"> <span data-i18n="cmb.full"></span></label>
|
||||
<p class="faint" id="cmbMem" style="font-size:12.5px;margin:6px 0 0"></p>
|
||||
<div id="cmbMemLow" class="msg warn hidden" data-i18n="cmb.mem.low"></div>
|
||||
<label class="row" id="cmbUploadRow" style="margin-top:10px;gap:8px;font-size:13.5px"><input type="checkbox" id="cmbUpload"> <span data-i18n="cmb.upload"></span></label>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:18px">
|
||||
<button id="cmbCancel" data-i18n="grp.cancel"></button>
|
||||
<button class="primary" id="cmbStart" data-i18n="cmb.start"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="renDlg">
|
||||
<h2 style="margin-top:0" id="renTitle"></h2>
|
||||
<p class="muted" id="renHint" style="font-size:13px"></p>
|
||||
<input id="renInput" type="text" maxlength="80" style="width:100%" autocomplete="off">
|
||||
<label id="renFrameRow" class="row hidden" style="margin-top:10px;gap:8px;font-size:13.5px"><input type="checkbox" id="renFrame"> <span data-i18n="ren.frame"></span></label>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:16px">
|
||||
<button id="renCancel" data-i18n="ren.cancel"></button>
|
||||
<button class="primary" id="renOk" data-i18n="ren.ok"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="cleanDlg">
|
||||
<h2 style="margin-top:0" id="cleanTitle"></h2>
|
||||
<p class="muted" style="font-size:13px" id="cleanIntro"></p>
|
||||
<div id="cleanList"></div>
|
||||
<div class="row" style="justify-content:space-between;align-items:center;margin-top:16px">
|
||||
<span class="faint" id="cleanTotal" style="font-size:13px"></span>
|
||||
<button id="cleanClose" data-i18n="clean.close"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="planDlg" class="plan">
|
||||
<div class="plan-head">
|
||||
<h2 id="planTitle"></h2>
|
||||
<div class="seg2" id="planStyle">
|
||||
<button data-style="pretty" class="active" data-i18n="plan.pretty"></button>
|
||||
<button data-style="classic" data-i18n="plan.classic"></button>
|
||||
</div>
|
||||
<button class="small" id="planMeasure" data-i18n="plan.measure" data-i18n-title="plan.measure.title"></button>
|
||||
<a class="btnlink" id="planSave" download data-i18n="plan.save"></a>
|
||||
<button class="small" id="planClose" data-i18n="plan.close"></button>
|
||||
</div>
|
||||
<div class="measure-bar hidden" id="measureBar">
|
||||
<span class="total" id="measureTotal"></span>
|
||||
<span class="muted" id="measureHint" data-i18n="plan.measure.hint"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="small" id="measureUndo" data-i18n="plan.measure.undo"></button>
|
||||
<button class="small" id="measureReset" data-i18n="plan.measure.reset"></button>
|
||||
</div>
|
||||
<div class="plan-img hidden" id="planBox"><div class="wait" id="planWait"></div>
|
||||
<div class="plan-stage hidden" id="planStage"><img id="planImg" alt=""><svg id="planSvg" xmlns="http://www.w3.org/2000/svg"></svg></div></div>
|
||||
<p class="faint" style="font-size:12px;margin:10px 0 0" id="planNote"></p>
|
||||
</dialog>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/i18n.js"></script>
|
||||
@@ -364,7 +491,9 @@ function renderFramePill() {
|
||||
p.className = "pill " + (S.frameOnline ? "on" : "off");
|
||||
p.lastElementChild.textContent = tr(!S.frameConfigured ? "frame.none" : S.frameOnline ? "frame.connected" : "frame.offline");
|
||||
$("#optFrame").classList.toggle("hidden", !S.frameConfigured);
|
||||
if (!S.frameConfigured) { $("input[name=target][value=pc]").checked = true; targetChanged(); }
|
||||
// Default target follows the Frame: on the Frame when it is reachable, otherwise PC only – until the user picks one
|
||||
const auto = !S.frameConfigured ? "pc" : S.targetTouched ? null : S.frameOnline ? "frame" : S.frameOnline === false ? "pc" : null;
|
||||
if (auto && auto !== target()) { $(`input[name=target][value=${auto}]`).checked = true; targetChanged(); }
|
||||
$("#targetWarn").classList.toggle("hidden", S.frameOnline !== false || !S.frameConfigured || target() !== "frame");
|
||||
}
|
||||
function currentList() {
|
||||
@@ -390,11 +519,11 @@ function renderRecs() {
|
||||
const play = onPc ? `<button class="play pc-only" data-play="${esc(f.name)}" title="${tr("play.title")}" aria-label="${tr("play.title")}">${PLAY}</button>` : "";
|
||||
return `<div class="rec${sel}" data-i="${i}" role="button" tabindex="0">
|
||||
<div class="thumb" data-src="/api/thumb?source=${onPc ? "local" : "frame"}&name=${encodeURIComponent(f.name)}">${badge(f)}${play}</div>
|
||||
<div class="meta"><b>${esc(fmtDate(f.date) || f.name)}</b><span>${mb(f.size)} ${local}</span></div></div>`;
|
||||
<div class="meta"><b>${esc(f.label || fmtDate(f.date) || f.name)}</b><span>${f.label && f.date ? esc(fmtDate(f.date)) + " · " : ""}${mb(f.size)} ${local}</span></div></div>`;
|
||||
}).join("");
|
||||
g.querySelectorAll(".thumb").forEach(t => {
|
||||
const url = t.dataset.src, img = new Image();
|
||||
img.onload = () => { t.style.backgroundImage = `url('${url}')`; };
|
||||
img.onload = () => { t.style.backgroundImage = `url(${JSON.stringify(url)})`; };
|
||||
img.onerror = () => { if (!t.querySelector(".play")) t.textContent = "◌"; };
|
||||
img.src = url;
|
||||
});
|
||||
@@ -427,7 +556,8 @@ async function select(f) {
|
||||
function autoName() {
|
||||
const f = S.sel;
|
||||
if (!f || ($("#name").value && $("#name").dataset.auto !== "1")) return;
|
||||
$("#name").value = f.date ? tr("name.auto", {date: fmtDate(f.date, true)}) : f.name.replace(/_stereo\.mp4$|\.mp4$/, "");
|
||||
const label = f.label || S.recs?.local.find(x => x.name === f.name)?.label;
|
||||
$("#name").value = label || (f.date ? tr("name.auto", {date: fmtDate(f.date, true)}) : f.name.replace(/_stereo\.mp4$|\.mp4$/, ""));
|
||||
$("#name").dataset.auto = "1";
|
||||
}
|
||||
async function runProbe(path) {
|
||||
@@ -457,8 +587,10 @@ function renderCheck() {
|
||||
if (!p.ok && !p.duration) return;
|
||||
box.insertAdjacentHTML("afterbegin", `<div class="row" style="order:9;margin-left:auto">
|
||||
<button class="small" id="checkSel" title="${tr("check.title")}">${tr("check.run")}</button>
|
||||
<button class="small pc-only" id="playSel">${tr("play.button")}</button></div>`);
|
||||
<button class="small pc-only" id="playSel">${tr("play.button")}</button>
|
||||
${S.recs?.local.find(x => x.name === f.name)?.renamable ? `<button class="small" id="renSel">${tr("ren.button")}</button>` : ""}</div>`);
|
||||
$("#playSel").onclick = () => play(f.name);
|
||||
if ($("#renSel")) $("#renSel").onclick = () => renameRecording(f);
|
||||
$("#checkSel").onclick = () => runCheck(f.name);
|
||||
loadCheck(f.name);
|
||||
}
|
||||
@@ -562,7 +694,7 @@ function updateStart() {
|
||||
document.querySelectorAll("#quality button").forEach(b => b.onclick = () => {
|
||||
S.quality = b.dataset.q; document.querySelectorAll("#quality button").forEach(x => x.classList.toggle("active", x === b)); updateStart();
|
||||
});
|
||||
document.querySelectorAll("input[name=target]").forEach(r => r.onchange = targetChanged);
|
||||
document.querySelectorAll("input[name=target]").forEach(r => r.onchange = () => { S.targetTouched = true; targetChanged(); });
|
||||
["frames", "scale", "maxg", "iter"].forEach(id => $("#" + id).oninput = updateStart);
|
||||
$("#name").oninput = () => { $("#name").dataset.auto = "0"; updateStart(); };
|
||||
function setTab(tab) { S.tab = tab; document.querySelectorAll(".tab").forEach(x => x.classList.toggle("active", x.dataset.tab === tab)); }
|
||||
@@ -593,7 +725,8 @@ async function start(existing) {
|
||||
frames: v("frames") || null, scale: v("scale") || null, maxg: v("maxg") ? Math.round(+v("maxg") * 1e6) : null, iter: v("iter") || null};
|
||||
const r = await api("/api/start", body);
|
||||
if (r.status === 409) {
|
||||
$("#existsText").textContent = tr("exists.text", {name: body.name});
|
||||
$("#existsText").textContent = tr(r.no_resume ? "exists.noresume." + r.no_resume : "exists.text", {name: body.name});
|
||||
$("#exResume").classList.toggle("hidden", !!r.no_resume);
|
||||
$("#existsDlg").showModal(); return;
|
||||
}
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
@@ -615,12 +748,13 @@ function renderJob() {
|
||||
if (!j || (!j.running && S.dismissed === j.out)) { card.classList.add("hidden"); return; }
|
||||
card.classList.remove("hidden");
|
||||
$("#jobName").textContent = j.name;
|
||||
const qk = QUALITY_ALIAS[j.quality] || j.quality;
|
||||
$("#jobSub").textContent = tr("job.sub", {rec: j.recording, q: tr("q." + qk), target: tr(j.upload ? "job.toFrame" : "job.pcOnly"),
|
||||
time: mins(Date.now() / 1000 - j.started)});
|
||||
const qk = QUALITY_ALIAS[j.quality] || j.quality, combine = j.kind === "combine";
|
||||
const target = tr(j.upload ? "job.toFrame" : "job.pcOnly"), time = mins(Date.now() / 1000 - j.started);
|
||||
$("#jobSub").textContent = combine ? tr("job.sub.combine", {n: j.rooms, align: ALIGN_LETTER[j.quality] || "?", target, time})
|
||||
: tr("job.sub", {rec: j.recording, q: tr("q." + qk), target, time});
|
||||
$("#cancelBtn").classList.toggle("hidden", !j.running);
|
||||
$("#dismissBtn").classList.toggle("hidden", j.running);
|
||||
const phases = [0, 1, 2, 3, 4].slice(0, j.upload ? 5 : 4).map(i => tr("phase." + i));
|
||||
const phases = [0, 1, 2, 3, 4].slice(0, j.upload ? 5 : 4).map(i => tr((combine && i < 4 ? "cphase." : "phase.") + i));
|
||||
const finished = !j.running && j.result === "ok";
|
||||
$("#steps").style.gridTemplateColumns = `repeat(${phases.length}, 1fr)`;
|
||||
$("#steps").innerHTML = phases.map((p, i) => {
|
||||
@@ -680,30 +814,374 @@ function uploadBtn(s) {
|
||||
const label = tr(s.on_frame ? "sc.reupload" : "sc.toFrame"), title = tr(s.on_frame ? "sc.reupload.title" : "sc.toFrame.title");
|
||||
return `<button class="small${s.on_frame ? "" : " primary"}" data-upload="${esc(s.slug)}" title="${title}" ${busy || s.on_frame === null ? "disabled" : ""}>${label}</button>`;
|
||||
}
|
||||
async function loadScenes() {
|
||||
const list = await api("/api/scenes");
|
||||
const arr = Array.isArray(list) ? list : Object.values(list).filter(x => typeof x === "object");
|
||||
$("#sceneGrid").innerHTML = arr.length ? arr.map(s => `
|
||||
function sceneCard(s) {
|
||||
return `
|
||||
<div class="rec scene" style="cursor:default">
|
||||
<div class="thumb" style="${s.preview ? `background-image:url('/api/preview?slug=${encodeURIComponent(s.slug)}&t=${s.mtime}')` : ""}">${s.preview ? "" : "◌"}</div>
|
||||
<div class="meta"><b>${esc(s.name)}</b>
|
||||
<span>${new Date(s.mtime * 1000).toLocaleDateString(LANG)} · ${s.gaussians ? tr("sc.splats", {n: num(s.gaussians / 1e6, 2)}) + " · " : ""}${mb(s.size)}</span>
|
||||
${frameState(s)}
|
||||
<div class="acts">${uploadBtn(s)}<button class="small pc-only" data-open="${esc(s.slug)}">${tr("sc.folder")}</button>${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}</div>
|
||||
</div></div>`).join("") : `<div class="empty" style="grid-column:1/-1">${tr("scenes.none")}</div>`;
|
||||
${s.disk ? `<span class="faint">${tr("sc.disk", {size: mb(s.disk.total)})}</span>` : ""}
|
||||
${s.replacing ? `<div class="fstate faint">${tr("sc.replacing")}</div>` : frameState(s)}
|
||||
<div class="acts${s.replacing ? " hidden" : ""}">${uploadBtn(s)}${s.ply ? `<button class="small" data-plan="${esc(s.slug)}">${tr("sc.plan")}</button>` : ""}<button class="small pc-only" data-open="${esc(s.slug)}">${tr("sc.folder")}</button>${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}<button class="small" data-rename="${esc(s.slug)}">${tr("ren.button")}</button><button class="small" data-clean="${esc(s.slug)}" title="${tr("sc.clean.title")}" aria-label="${tr("sc.clean.title")}">${tr("sc.clean")}</button><button class="small" data-delete="${esc(s.slug)}" title="${tr("sc.delete.title")}" aria-label="${tr("sc.delete.title")}">${tr("sc.delete")}</button></div>
|
||||
</div></div>`;
|
||||
}
|
||||
const ALIGN_LETTER = {frame: "A", images: "B", shift: "C", rigid: "D"};
|
||||
function groupOpen(slug, set) {
|
||||
let open = [];
|
||||
try { open = JSON.parse(localStorage.getItem("openGroups") || "[]"); } catch {}
|
||||
if (set === undefined) return open.includes(slug);
|
||||
open = open.filter(x => x !== slug); if (set) open.push(slug);
|
||||
try { localStorage.setItem("openGroups", JSON.stringify(open)); } catch {}
|
||||
}
|
||||
function groupBlock(g, bySlug) {
|
||||
const rooms = g.members.map(m => bySlug[m]).filter(Boolean), r = g.result, open = groupOpen(g.slug);
|
||||
const disk = rooms.reduce((a, s) => a + (s.disk ? s.disk.total : 0), 0) + (r && r.disk ? r.disk.total : 0);
|
||||
let tour = `<span>${tr("grp.none")}</span>`;
|
||||
if (r) {
|
||||
const frameN = Math.min(r.frame_splats || r.splats || 0, r.splats || 0), seams = Object.values(r.seams || {}).map(x => x.cm).sort((a, b) => a - b);
|
||||
const med = seams.length ? seams[Math.floor(seams.length / 2)] : null, max = seams.length ? seams[seams.length - 1] : null;
|
||||
const detail = Object.entries(r.seams || {}).map(([k, v]) => `${k.replace("|", " – ")}: ${num(v.cm, 1)} cm`).join("\n");
|
||||
tour = `<span>${tr("grp.tour", {frame: tr("million", {n: num(frameN / 1e6, 1)}), fsize: mb(r.size),
|
||||
full: r.full_size ? tr("grp.tour.full", {n: tr("million", {n: num((r.splats || 0) / 1e6, 1)}), size: mb(r.full_size)}) : ""})}</span>
|
||||
<span title="${esc(detail)}">${med != null ? tr("grp.seams", {med: num(med, 1), max: num(max, 1), align: ALIGN_LETTER[r.align] || "?"}) : ""}</span>
|
||||
${r.stale ? `<span class="warn">${tr("grp.stale")}</span>` : ""}
|
||||
${frameState({upload: r.upload, on_frame: r.on_frame})}`;
|
||||
}
|
||||
const thumb = r && r.preview ? `style="background-image:url('/api/preview?slug=${encodeURIComponent(g.slug)}&t=${r.mtime}')"` : "";
|
||||
const busy = g.busy ? `<span>${tr("grp.busy")}</span>` : "";
|
||||
return `<div class="group">
|
||||
<div class="ghead">
|
||||
<div class="gthumb" ${thumb}>${r && r.preview ? "" : "📁"}</div>
|
||||
<div class="gmeta"><b>📁 ${esc(g.name)}</b>
|
||||
<span>${tr("grp.rooms", {n: rooms.length})} · ${tr("sc.disk", {size: mb(disk)})}</span>
|
||||
${tour}${busy}
|
||||
<div class="acts">
|
||||
<button class="small" data-gtoggle="${esc(g.slug)}" aria-expanded="${open}">${tr(open ? "grp.hide" : "grp.show", {n: rooms.length})}</button>
|
||||
<button class="small${!r || r.stale ? " primary" : ""}" data-combine="${esc(g.slug)}" ${g.busy || rooms.length < 2 ? "disabled" : ""}>${tr(r ? "grp.recombine" : "grp.combine")}</button>
|
||||
${r ? uploadBtn({slug: g.slug, on_frame: r.on_frame, upload: r.upload}) : ""}
|
||||
${rooms.some(s => s.ply) ? `<button class="small" data-gplan="${esc(g.slug)}">${tr("sc.plan")}</button>` : ""}
|
||||
${r && r.ply ? `<button class="small pc-only" data-spirula="${esc(g.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}
|
||||
${r ? `<button class="small pc-only" data-open="${esc(g.slug)}">${tr("sc.folder")}</button>` : ""}
|
||||
<button class="small" data-gedit="${esc(g.slug)}">${tr("grp.edit")}</button>
|
||||
<button class="small" data-clean="${esc(g.slug)}" title="${tr("grp.clean.title")}" aria-label="${tr("grp.clean.title")}" ${g.busy ? "disabled" : ""}>${tr("sc.clean")}</button>
|
||||
<button class="small" data-gdelete="${esc(g.slug)}" title="${tr("grp.delete.title")}" aria-label="${tr("grp.delete.title")}">${tr("sc.delete")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${open ? `<div class="grid ginner">${rooms.map(sceneCard).join("")}</div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
async function loadScenes() {
|
||||
const [list, gr] = await Promise.all([api("/api/scenes"), api("/api/groups")]);
|
||||
const arr = Array.isArray(list) ? list : Object.values(list).filter(x => x && typeof x === "object" && x.slug);
|
||||
S.scenes = arr; S.groups = gr.groups || []; S.memGB = gr.mem_available_gb;
|
||||
const bySlug = Object.fromEntries(arr.map(s => [s.slug, s]));
|
||||
const inGroup = new Set(S.groups.flatMap(g => g.members));
|
||||
$("#groupNewBtn").classList.toggle("hidden", !arr.some(s => !inGroup.has(s.slug)));
|
||||
const sug = gr.suggestion;
|
||||
S.suggestion = sug;
|
||||
$("#groupSuggest").classList.toggle("hidden", !sug);
|
||||
if (sug) {
|
||||
const day = new Date(sug.date).toLocaleDateString(LANG, {day: "numeric", month: "numeric"});
|
||||
$("#groupSuggest").innerHTML = `${esc(tr("grp.suggest", {n: sug.members.length, date: day,
|
||||
names: sug.members.map(m => bySlug[m] ? bySlug[m].name : m).join(", ")}))}
|
||||
<div class="row"><button class="small primary" id="sugYes">${tr("grp.suggest.yes")}</button><button class="small" id="sugNo">${tr("grp.suggest.no")}</button></div>`;
|
||||
$("#sugYes").onclick = () => openGroupDlg(null, {members: sug.members, name: tr("grp.suggest.name", {date: day})});
|
||||
$("#sugNo").onclick = async () => { await api("/api/group/dismiss", {key: sug.key}); loadScenes(); };
|
||||
}
|
||||
$("#groupList").innerHTML = S.groups.map(g => groupBlock(g, bySlug)).join("");
|
||||
const loose = arr.filter(s => !inGroup.has(s.slug));
|
||||
$("#sceneGrid").innerHTML = loose.length ? loose.map(sceneCard).join("")
|
||||
: S.groups.length ? "" : `<div class="empty" style="grid-column:1/-1">${tr("scenes.none")}</div>`;
|
||||
document.querySelectorAll("[data-gtoggle]").forEach(b => b.onclick = () => { groupOpen(b.dataset.gtoggle, !groupOpen(b.dataset.gtoggle)); loadScenes(); });
|
||||
document.querySelectorAll("[data-gedit]").forEach(b => b.onclick = () => openGroupDlg(S.groups.find(g => g.slug === b.dataset.gedit)));
|
||||
document.querySelectorAll("[data-gdelete]").forEach(b => b.onclick = () => deleteGroup(S.groups.find(g => g.slug === b.dataset.gdelete)));
|
||||
document.querySelectorAll("[data-combine]").forEach(b => b.onclick = () => openCombine(S.groups.find(g => g.slug === b.dataset.combine)));
|
||||
document.querySelectorAll("[data-gplan]").forEach(b => b.onclick = () => {
|
||||
const g = S.groups.find(x => x.slug === b.dataset.gplan);
|
||||
openPlan(g.members.filter(m => bySlug[m] && bySlug[m].ply), g.name);
|
||||
});
|
||||
document.querySelectorAll("[data-upload]").forEach(b => b.onclick = async () => {
|
||||
const r = await api("/api/upload", {slug: b.dataset.upload});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
toast(tr("sc.uploadStarted")); loadScenes();
|
||||
});
|
||||
document.querySelectorAll("[data-plan]").forEach(b => b.onclick = () => {
|
||||
const sc = S.scenes.find(x => x.slug === b.dataset.plan);
|
||||
openPlan([sc.slug], sc.name);
|
||||
});
|
||||
document.querySelectorAll("[data-open]").forEach(b => b.onclick = () => api("/api/open", {slug: b.dataset.open, what: "folder"}));
|
||||
document.querySelectorAll("[data-spirula]").forEach(b => b.onclick = () => { api("/api/open", {slug: b.dataset.spirula, what: "spirula"}); toast(tr("sc.spirula.starting")); });
|
||||
document.querySelectorAll("[data-rename]").forEach(b => b.onclick = () => renameScene(S.scenes.find(x => x.slug === b.dataset.rename)));
|
||||
document.querySelectorAll("[data-clean]").forEach(b => b.onclick = () => openClean(b.dataset.clean));
|
||||
document.querySelectorAll("[data-delete]").forEach(b => b.onclick = () => deleteScene(S.scenes.find(x => x.slug === b.dataset.delete)));
|
||||
loadUnfinished();
|
||||
}
|
||||
async function loadUnfinished() {
|
||||
const list = await api("/api/unfinished"); // api() spreads the array into an object
|
||||
const arr = Object.values(list).filter(x => x && typeof x === "object");
|
||||
S.unfinished = arr;
|
||||
$("#unfinCard").classList.toggle("hidden", !arr.length);
|
||||
const state = u => u.phase === null ? tr(u.folder ? "unfin.empty" : "unfin.logonly")
|
||||
: tr("unfin.stopped", {state: tr("phase." + u.phase) + (u.pct != null ? ` ${u.pct} %` : "")});
|
||||
$("#unfinList").innerHTML = arr.map(u => `<div class="row">
|
||||
<b>${esc(u.name)}</b>
|
||||
<span class="muted" style="font-size:12.5px">${new Date(u.mtime * 1000).toLocaleDateString(LANG)} · ${state(u)} · ${mb(u.size)}</span>
|
||||
<span class="sp"></span>
|
||||
${u.resumable ? `<button class="small" data-resume="${esc(u.slug)}">${tr("unfin.resume")}</button>`
|
||||
: u.restartable ? `<button class="small" data-resume="${esc(u.slug)}" data-fresh="1" title="${tr("unfin.restart.title")}">${tr("unfin.restart")}</button>` : ""}
|
||||
<button class="small" data-udelete="${esc(u.slug)}" title="${tr("unfin.delete.title")}" aria-label="${tr("unfin.delete.title")}">${tr("sc.delete")}</button>
|
||||
</div>`).join("");
|
||||
document.querySelectorAll("[data-resume]").forEach(b => b.onclick = async () => {
|
||||
const r = await api("/api/resume", {slug: b.dataset.resume, fresh: !!b.dataset.fresh});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
toast(tr("unfin.resumed")); poll(); loadUnfinished();
|
||||
});
|
||||
document.querySelectorAll("[data-udelete]").forEach(b => b.onclick = async () => {
|
||||
const u = S.unfinished.find(x => x.slug === b.dataset.udelete);
|
||||
if (!confirm(tr("unfin.confirm", {name: u.name, size: mb(u.size)}))) return;
|
||||
const r = await api("/api/delete", {slug: u.slug});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
toast(tr("del.done", {name: u.name})); loadUnfinished();
|
||||
});
|
||||
}
|
||||
async function deleteScene(sc) {
|
||||
const size = mb(sc.disk ? sc.disk.total : sc.size);
|
||||
if (!confirm(tr("del.confirm", {name: sc.name, size, frame: sc.on_frame ? tr("del.frame") : ""}))) return;
|
||||
const r = await api("/api/delete", {slug: sc.slug});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
toast(tr("del.done", {name: sc.name})); loadScenes();
|
||||
}
|
||||
|
||||
/* ---------- groups and tours ---------- */
|
||||
function openGroupDlg(g, preset) {
|
||||
const members = new Set(g ? g.members : (preset && preset.members) || []);
|
||||
const owner = {};
|
||||
(S.groups || []).forEach(x => { if (!g || x.slug !== g.slug) x.members.forEach(m => owner[m] = x.name); });
|
||||
$("#groupTitle").textContent = tr(g ? "grp.edit.title" : "grp.new.title");
|
||||
$("#groupName").value = g ? g.name : (preset && preset.name) || "";
|
||||
// One group per scene: scenes of other groups are not offered at all
|
||||
const free = S.scenes.filter(sc => !owner[sc.slug]);
|
||||
$("#groupPick").innerHTML = free.length ? free.map(sc => `<label><input type="checkbox" value="${esc(sc.slug)}" ${members.has(sc.slug) ? "checked" : ""}>
|
||||
${esc(sc.name)}</label>`).join("") : `<p class="faint" style="font-size:13px;margin:0">${tr("grp.noFree")}</p>`;
|
||||
$("#groupDlg").showModal(); $("#groupName").select();
|
||||
$("#groupCancel").onclick = () => $("#groupDlg").close();
|
||||
$("#groupSave").onclick = async () => {
|
||||
const picked = [...document.querySelectorAll("#groupPick input:checked")].map(i => i.value);
|
||||
const r = await api("/api/group", {slug: g ? g.slug : "", name: $("#groupName").value.trim(), members: picked.join(",")});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
$("#groupDlg").close(); groupOpen(r.slug, true); loadScenes();
|
||||
};
|
||||
}
|
||||
async function deleteGroup(g) {
|
||||
const r = g.result;
|
||||
if (!confirm(tr("grp.delete.confirm", {name: g.name, tour: r ? tr("grp.delete.tour", {size: mb(r.disk ? r.disk.total : r.size)}) : ""}))) return;
|
||||
const res = await api("/api/delete", {slug: g.slug});
|
||||
if (!res.ok) { toast(trErr(res.error)); return; }
|
||||
toast(tr("grp.deleted")); loadScenes();
|
||||
}
|
||||
function openCombine(g) {
|
||||
const o = g.options || {};
|
||||
const radios = (box, name, values, cur) => { $(box).innerHTML = values.map(v => `<label class="opt${v === cur ? " active" : ""}">
|
||||
<input type="radio" name="${name}" value="${v}" ${v === cur ? "checked" : ""}><div><b>${tr(`${name}.${v}`)}</b><span>${tr(`${name}.${v}.text`)}</span></div></label>`).join("");
|
||||
$(box).querySelectorAll("input").forEach(i => i.onchange = () =>
|
||||
$(box).querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked))); };
|
||||
radios("#cmbAlign", "cmb.align", ["frame", "images", "shift", "rigid"], o.align || "images");
|
||||
radios("#cmbPart", "cmb.part", ["view", "nearest"], o.partition || "view");
|
||||
$("#cmbTitle").textContent = tr("cmb.title", {name: g.name});
|
||||
$("#cmbSize").value = String(o.frame_splats || 3500000);
|
||||
// memory, measured on six rooms: reduce ~0.45 GB per million splats, full SOG ~0.65 GB per million
|
||||
const total = (g.splats || 0) * 0.85 / 1e6, needFrame = Math.max(1.5, 0.45 * total), needFull = Math.max(needFrame, 0.65 * total);
|
||||
const avail = S.memGB || 0, lowFull = avail && needFull > avail;
|
||||
$("#cmbMem").textContent = tr("cmb.mem", {frame: num(needFrame, 1), full: num(needFull, 1), avail: num(avail, 1)});
|
||||
$("#cmbFull").checked = o.full !== false && !lowFull;
|
||||
const updMem = () => $("#cmbMemLow").classList.toggle("hidden", !(avail && ($("#cmbFull").checked ? needFull : needFrame) > avail));
|
||||
$("#cmbFull").onchange = updMem; updMem();
|
||||
$("#cmbUploadRow").classList.toggle("hidden", !S.frameConfigured);
|
||||
$("#cmbUpload").checked = !!S.frameOnline;
|
||||
$("#combineDlg").showModal();
|
||||
$("#cmbCancel").onclick = () => $("#combineDlg").close();
|
||||
$("#cmbStart").onclick = async () => {
|
||||
const r = await api("/api/combine", {slug: g.slug, align: $("input[name='cmb.align']:checked").value,
|
||||
partition: $("input[name='cmb.part']:checked").value, frame_splats: $("#cmbSize").value,
|
||||
full: $("#cmbFull").checked, upload: S.frameConfigured && $("#cmbUpload").checked});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
$("#combineDlg").close(); toast(tr("cmb.started")); await poll(); loadScenes();
|
||||
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- renaming ---------- */
|
||||
function askName(title, hint, value, frameOption) {
|
||||
$("#renTitle").textContent = title; $("#renHint").textContent = hint;
|
||||
$("#renInput").value = value; $("#renFrameRow").classList.toggle("hidden", !frameOption);
|
||||
$("#renFrame").checked = !!frameOption;
|
||||
$("#renDlg").showModal(); $("#renInput").select();
|
||||
return new Promise(res => {
|
||||
const done = v => { $("#renDlg").close(); res(v); };
|
||||
$("#renOk").onclick = () => done($("#renInput").value.trim() || null);
|
||||
$("#renCancel").onclick = () => done(null);
|
||||
$("#renInput").onkeydown = e => { if (e.key === "Enter") { e.preventDefault(); $("#renOk").click(); } };
|
||||
$("#renDlg").oncancel = () => res(null);
|
||||
});
|
||||
}
|
||||
async function renameRecording(f) {
|
||||
const cur = S.recs.local.find(x => x.name === f.name) || f;
|
||||
const v = await askName(tr("ren.rec.title"), tr("ren.rec.hint"), cur.label || f.name.replace(/\.mp4$/, ""));
|
||||
if (!v || v + ".mp4" === f.name) return;
|
||||
const r = await api("/api/rename", {name: f.name, new: v});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
toast(tr("ren.done"));
|
||||
if ($("#name").dataset.auto === "1") $("#name").value = "";
|
||||
await loadRecs();
|
||||
const nf = S.recs.local.find(x => x.name === r.name);
|
||||
if (nf) select(nf); else { S.sel = null; renderCheck(); updateStart(); }
|
||||
}
|
||||
async function renameScene(sc) {
|
||||
const v = await askName(tr("ren.scene.title"), tr("ren.scene.hint"), sc.name, sc.on_frame === true);
|
||||
if (!v) return; // the same name again still tidies up the folder name
|
||||
const upd = sc.on_frame === true && $("#renFrame").checked;
|
||||
const r = await api("/api/rename", {slug: sc.slug, new: v, update_frame: upd});
|
||||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||||
toast(tr(upd ? "ren.frame.started" : "ren.done")); loadScenes();
|
||||
}
|
||||
|
||||
/* ---------- cleaning up ---------- */
|
||||
function openClean(slug) {
|
||||
// a scene, or a group: then the same kind of data of all its scenes together
|
||||
const g = (S.groups || []).find(x => x.slug === slug);
|
||||
let sc = S.scenes.find(x => x.slug === slug);
|
||||
if (g) {
|
||||
const rooms = g.members.map(m => S.scenes.find(x => x.slug === m)).filter(x => x && x.disk);
|
||||
const sum = k => rooms.reduce((a, x) => a + (x.disk[k] || 0), 0);
|
||||
sc = {name: g.name, disk: {temp: sum("temp"), images: sum("images"), total: sum("total")}, n: rooms.length};
|
||||
}
|
||||
if (!sc || !sc.disk) return;
|
||||
$("#cleanTitle").textContent = tr("clean.title", {name: sc.name});
|
||||
$("#cleanIntro").textContent = g ? tr("clean.intro.group", {n: sc.n}) : tr("clean.intro");
|
||||
$("#cleanTotal").textContent = tr(g ? "clean.total.group" : "clean.total", {size: mb(sc.disk.total)});
|
||||
$("#cleanList").innerHTML = ["temp", "images"].map(k => {
|
||||
const size = sc.disk[k] || 0, empty = size < 1e6;
|
||||
return `<div class="finding" style="display:flex;gap:12px;align-items:center;margin-top:10px">
|
||||
<div style="flex:1"><b>${tr("clean." + k)} · ${empty ? tr("clean.empty") : mb(size)}</b><span style="display:block;font-size:12.5px" class="muted">${tr("clean." + k + ".text")}</span></div>
|
||||
<button class="small" data-del="${k}" ${empty ? "disabled" : ""}>${tr("clean.delete")}</button></div>`;
|
||||
}).join("");
|
||||
$("#cleanList").querySelectorAll("[data-del]").forEach(b => b.onclick = async () => {
|
||||
const k = b.dataset.del, size = mb(sc.disk[k]);
|
||||
if (!confirm(tr(g ? "clean.confirm.group" : "clean.confirm", {what: tr("clean." + k), name: sc.name, size, n: sc.n}))) return;
|
||||
b.disabled = true;
|
||||
const r = await api("/api/clean", {slug, what: k});
|
||||
if (!r.ok) { toast(trErr(r.error)); b.disabled = false; return; }
|
||||
toast(tr("clean.done", {size}));
|
||||
await loadScenes(); openClean(slug);
|
||||
});
|
||||
if (!$("#cleanDlg").open) $("#cleanDlg").showModal();
|
||||
}
|
||||
$("#cleanClose").onclick = () => $("#cleanDlg").close();
|
||||
|
||||
/* ---------- floor plan ---------- */
|
||||
const P = {slugs: [], name: "", style: "pretty"};
|
||||
function planUrl(download) {
|
||||
const q = new URLSearchParams({slugs: P.slugs.join(","), style: P.style, lang: LANG,
|
||||
title: P.slugs.length > 1 ? P.name : ""});
|
||||
if (download) { q.set("download", "1"); q.set("name", `${P.name}-${tr("plan.file")}`); }
|
||||
return "/api/floorplan?" + q;
|
||||
}
|
||||
function openPlan(slugs, name) {
|
||||
P.slugs = slugs; P.name = name;
|
||||
$("#planTitle").textContent = tr(slugs.length > 1 ? "plan.title.all" : "plan.title", {name});
|
||||
$("#planStyle").classList.remove("hidden"); $("#planSave").classList.remove("hidden"); $("#planMeasure").classList.remove("hidden");
|
||||
if (!$("#planDlg").open) $("#planDlg").showModal();
|
||||
drawPlan();
|
||||
}
|
||||
async function drawPlan() {
|
||||
const box = $("#planBox"), img = $("#planImg"), wait = $("#planWait"), stage = $("#planStage");
|
||||
box.classList.remove("hidden"); stage.classList.add("hidden");
|
||||
wait.classList.remove("hidden"); wait.textContent = tr("plan.drawing");
|
||||
$("#planNote").textContent = tr(P.style === "pretty" ? "plan.note.pretty" : "plan.note.classic");
|
||||
document.querySelectorAll("#planStyle button").forEach(b => b.classList.toggle("active", b.dataset.style === P.style));
|
||||
$("#planSave").href = planUrl(true);
|
||||
const url = planUrl(false);
|
||||
P.want = url;
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error();
|
||||
const ppm = parseFloat(r.headers.get("X-Pixels-Per-Metre"));
|
||||
const blob = await r.blob();
|
||||
if (P.want !== url) return; // another plan was requested meanwhile
|
||||
if (img.src.startsWith("blob:")) URL.revokeObjectURL(img.src);
|
||||
img.onload = () => {
|
||||
M.ppm = Number.isFinite(ppm) && ppm > 0 ? ppm : 0; M.w = img.naturalWidth; M.h = img.naturalHeight; M.pts = [];
|
||||
$("#planSvg").setAttribute("viewBox", `0 0 ${M.w} ${M.h}`);
|
||||
wait.classList.add("hidden"); stage.classList.remove("hidden"); renderMeasure();
|
||||
};
|
||||
img.src = URL.createObjectURL(blob);
|
||||
} catch { if (P.want === url) wait.textContent = tr("err.plan_failed"); }
|
||||
}
|
||||
|
||||
/* measuring: points in image pixels, distances via pixels per metre */
|
||||
const M = {on: false, pts: [], hover: null, ppm: 0, w: 0, h: 0};
|
||||
function fmtM(px) {
|
||||
if (!M.ppm) return "?"; // scale unknown (older server) – still draw the points
|
||||
const m = px / M.ppm; return m < 1 ? `${Math.round(m * 100)} cm` : `${num(m, 2)} m`;
|
||||
}
|
||||
function svgPoint(e) {
|
||||
const r = $("#planSvg").getBoundingClientRect();
|
||||
return [(e.clientX - r.left) / r.width * M.w, (e.clientY - r.top) / r.height * M.h];
|
||||
}
|
||||
function renderMeasure() {
|
||||
$("#planMeasure").classList.toggle("on", M.on);
|
||||
$("#planStage").classList.toggle("measuring", M.on);
|
||||
$("#measureBar").classList.toggle("hidden", !M.on);
|
||||
const svg = $("#planSvg"), k = M.w / 1000; // sizes relative to the image width
|
||||
if (!M.on || !M.w) { svg.innerHTML = ""; return; }
|
||||
const pts = M.hover && M.pts.length ? [...M.pts, M.hover] : M.pts;
|
||||
let total = 0, out = "";
|
||||
const label = (x, y, t) => `<text x="${x}" y="${y}" text-anchor="middle" font-family="Inter, Noto Sans, sans-serif" font-weight="650"
|
||||
font-size="${15 * k}" fill="#0b0d12" stroke="#0b0d12" stroke-width="${5 * k}" stroke-linejoin="round" opacity=".85">${t}</text>
|
||||
<text x="${x}" y="${y}" text-anchor="middle" font-family="Inter, Noto Sans, sans-serif" font-weight="650" font-size="${15 * k}" fill="#ffd166">${t}</text>`;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const [x1, y1] = pts[i - 1], [x2, y2] = pts[i], d = Math.hypot(x2 - x1, y2 - y1);
|
||||
const preview = M.hover && i === pts.length - 1;
|
||||
total += d;
|
||||
out += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#0b0d12" stroke-width="${6 * k}" stroke-linecap="round" opacity=".6"/>
|
||||
<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#ffd166" stroke-width="${2.6 * k}" stroke-linecap="round" ${preview ? `stroke-dasharray="${8 * k} ${6 * k}"` : ""}/>`;
|
||||
if (d * 1 > 30 * k) out += label((x1 + x2) / 2, (y1 + y2) / 2 - 9 * k, fmtM(d));
|
||||
}
|
||||
M.pts.forEach(([x, y], i) => out += `<circle cx="${x}" cy="${y}" r="${(i === 0 ? 6 : 4.5) * k}" fill="#ffd166" stroke="#0b0d12" stroke-width="${2 * k}"/>`);
|
||||
svg.innerHTML = out;
|
||||
const fixed = M.pts.slice(1).reduce((acc, p, i) => acc + Math.hypot(p[0] - M.pts[i][0], p[1] - M.pts[i][1]), 0);
|
||||
$("#measureTotal").textContent = !M.ppm ? tr("plan.measure.noscale") : M.pts.length < 2 && !M.hover ? tr("plan.measure.start")
|
||||
: tr("plan.measure.total", {d: fmtM(M.hover && M.pts.length ? total : fixed)});
|
||||
$("#measureUndo").disabled = !M.pts.length; $("#measureReset").disabled = !M.pts.length;
|
||||
}
|
||||
$("#planMeasure").onclick = () => { M.on = !M.on; M.pts = []; M.hover = null; renderMeasure(); };
|
||||
$("#planSvg").addEventListener("click", e => { if (!M.on) return; M.pts.push(svgPoint(e)); renderMeasure(); });
|
||||
$("#planSvg").addEventListener("mousemove", e => { if (!M.on || !M.pts.length) return; M.hover = svgPoint(e); renderMeasure(); });
|
||||
$("#planSvg").addEventListener("mouseleave", () => { M.hover = null; renderMeasure(); });
|
||||
$("#planSvg").addEventListener("contextmenu", e => { if (!M.on) return; e.preventDefault(); M.pts.pop(); renderMeasure(); });
|
||||
$("#measureUndo").onclick = () => { M.pts.pop(); renderMeasure(); };
|
||||
$("#measureReset").onclick = () => { M.pts = []; M.hover = null; renderMeasure(); };
|
||||
document.querySelectorAll("#planStyle button").forEach(b => b.onclick = () => { P.style = b.dataset.style; drawPlan(); });
|
||||
$("#planClose").onclick = () => $("#planDlg").close();
|
||||
$("#planDlg").addEventListener("close", () => { M.on = false; M.pts = []; M.hover = null; renderMeasure(); });
|
||||
$("#planDlg").addEventListener("click", e => {
|
||||
// Only a click on the backdrop closes it, not one on the dialog's own padding
|
||||
const r = $("#planDlg").getBoundingClientRect();
|
||||
if (e.target === $("#planDlg") && (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom)) $("#planDlg").close();
|
||||
});
|
||||
$("#groupNewBtn").onclick = () => openGroupDlg(null);
|
||||
|
||||
/* ---------- polling ---------- */
|
||||
let wasRunning = null;
|
||||
async function poll() {
|
||||
const r = await api("/api/status");
|
||||
if (document.hidden) return; // no polling in a background tab
|
||||
let r;
|
||||
try { r = await api("/api/status"); } catch { r = null; }
|
||||
const offline = !r || r.status >= 500;
|
||||
$("#offline").classList.toggle("hidden", !offline);
|
||||
document.body.classList.toggle("offline", offline);
|
||||
if (offline) return;
|
||||
S.job = r.job; S.dl = r.download;
|
||||
if (S.dl && S.dl.finished && S.dlWait && S.sel && S.dl.name === S.sel.name) {
|
||||
const res = S.dlWait; S.dlWait = null;
|
||||
@@ -720,11 +1198,12 @@ async function poll() {
|
||||
}
|
||||
api("/api/ping").then(r => {
|
||||
document.body.classList.toggle("remote", r.local === false);
|
||||
S.ping = r;
|
||||
S.ping = r; S.frameConfigured = r.frame_configured;
|
||||
setupLang(r.language);
|
||||
loadRecs(); poll();
|
||||
});
|
||||
setInterval(poll, 2000);
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user