# /// 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 Reads the initial view from the log ("Gaussian XR initial view: cam0/... Display rotation X°") and writes .gallery.png and .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 main() -> None: 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] image, rotation = initial_view(run, log) gallery(image, rotation, run / f"{stem}.gallery.png") label(name, run / f"{stem}.sceneinfo.png") print(f"Preview image from {image.name} (rotation {rotation:g}°), name plate \"{name}\"") if __name__ == "__main__": main()