Files
thunderheronight--splat-wor…/scripts/make_gallery.py
T
thunderheronightandClaude Opus 5.5 7e64f039d6 Versions: several sizes and scales of a scene or tour side by side, each copyable to the Frame
For the Frame 0.75 / 1 / 1.5 / 2 M, for the PC all / 3 / 5 / 8 M, plus an own size; with a scale
also walkable in the original's size. Every version is a complete scene in <run>/versions/<key>/
and gets its own scene folder on the Frame, so e.g. 0.75 M and 2 M can be compared in the gallery.
The tour dialog offers 0.75–2 M or an own size for its Frame version. Measured frame rates are
shown as information only. 14 new tests (240 in all).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:15:44 +02:00

123 lines
5.3 KiB
Python

# /// script
# requires-python = ">=3.11"
# dependencies = ["pillow"]
# ///
"""Create the preview image (512x512) and name plate (358x86) for the Gaussian XR gallery.
Usage: uv run make_gallery.py <run folder> <pipeline log> <display name> <file stem>
uv run make_gallery.py --plan <floor plan.png> <run folder> <display name> <file stem>
uv run make_gallery.py --label <name plate.png> <text>
Reads the initial view from the log ("Gaussian XR initial view: cam0/... Display rotation X°")
and writes <file stem>.gallery.png and <file stem>.sceneinfo.png into the run folder.
The look matches the example scenes that ship with Gaussian XR.
"""
import re
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont, ImageStat
FONTS = [
"/usr/share/fonts/google-noto/NotoSans-Regular.ttf",
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
"/usr/share/fonts/liberation-sans/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", # Debian/Ubuntu
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/noto/NotoSans-Regular.ttf", # Arch
"/usr/share/fonts/TTF/DejaVuSans.ttf",
]
def initial_view(run: Path, log: Path) -> tuple[Path, float]:
match = None
for match in re.finditer(
r"Gaussian XR initial view: (\S+\.jpg) .*?Display rotation (-?[\d.]+)°",
log.read_text(errors="replace"),
):
pass # the last message counts
if match:
image = next(run.glob(f"dataset/images/{match.group(1)}"), None)
if image:
return image, float(match.group(2))
# No message: an image from the middle of the recording, not rotated
images = sorted(run.glob("dataset/images/cam0/*.jpg"))
if not images:
sys.exit(f"No images found in {run}/dataset/images/cam0")
return images[len(images) // 2], 0.0
def gallery(image: Path, rotation_ccw: float, out: Path) -> None:
# PIL rotates counter-clockwise for positive angles; the pipeline already reports the CCW rotation
img = Image.open(image).convert("RGB").rotate(rotation_ccw, expand=True)
w, h = img.size
side = int(min(w, h) * 0.8) # cut away the fisheye border
img = img.crop(((w - side) // 2, (h - side) // 2, (w + side) // 2, (h + side) // 2))
img = img.resize((512, 512), Image.LANCZOS)
# Brighten dark indoor shots without blowing out bright ones
brightness = ImageStat.Stat(img.convert("L")).mean[0]
if brightness < 110:
img = ImageEnhance.Brightness(img).enhance(min(2.2, 110 / max(brightness, 1)))
img = ImageEnhance.Contrast(img).enhance(1.1)
img.convert("RGBA").save(out)
def label(text: str, out: Path) -> None:
width, height = 358, 86
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
shadow = Image.new("RGBA", (width, height), (0, 0, 0, 0))
ImageDraw.Draw(shadow).rounded_rectangle(
(10, 10, width - 10, height - 10), radius=16, fill=(40, 46, 60, 200))
img.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(4)))
draw = ImageDraw.Draw(img)
draw.rounded_rectangle((14, 12, width - 14, height - 14), radius=14, fill=(64, 72, 90, 255))
font_path = next((p for p in FONTS if Path(p).exists()), None)
size = 34
while True: # shrink long names until they fit on the plate
font = ImageFont.truetype(font_path, size) if font_path else ImageFont.load_default()
box = draw.textbbox((0, 0), text, font=font)
if box[2] - box[0] <= width - 50 or size <= 14 or not font_path:
break
size -= 2
draw.text(
((width - (box[2] - box[0])) / 2 - box[0], (height - (box[3] - box[1])) / 2 - box[1] - 1),
text, font=font, fill=(255, 255, 255, 255))
img.save(out)
def from_plan(plan: Path, out: Path) -> None:
"""Preview of a combined scene: its floor plan, centred on a square."""
img = Image.open(plan).convert("RGB")
side = max(img.size)
square = Image.new("RGB", (side, side), img.getpixel((5, 5)))
square.paste(img, ((side - img.width) // 2, (side - img.height) // 2))
square.resize((512, 512), Image.LANCZOS).convert("RGBA").save(out)
def main() -> None:
if len(sys.argv) == 4 and sys.argv[1] == "--label":
label(sys.argv[3], Path(sys.argv[2]))
return
if len(sys.argv) == 6 and sys.argv[1] == "--plan":
plan, run, name, stem = Path(sys.argv[2]), Path(sys.argv[3]), sys.argv[4], sys.argv[5]
from_plan(plan, run / f"{stem}.gallery.png")
label(name, run / f"{stem}.sceneinfo.png")
print(f"Preview image from the floor plan, name plate \"{name}\"")
return
if len(sys.argv) != 5:
sys.exit(__doc__)
run, log, name, stem = Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3], sys.argv[4]
preview = run / f"{stem}.gallery.png"
if preview.exists() and not any(run.glob("dataset/images/cam0/*.jpg")):
# Training images were cleaned up: keep the existing preview, only renew the name plate
label(name, run / f"{stem}.sceneinfo.png")
print(f"Name plate \"{name}\" (preview image kept)")
return
image, rotation = initial_view(run, log)
gallery(image, rotation, preview)
label(name, run / f"{stem}.sceneinfo.png")
print(f"Preview image from {image.name} (rotation {rotation:g}°), name plate \"{name}\"")
if __name__ == "__main__":
main()