Files
2026-10-11 22:06:14 +02:00

248 lines
8.4 KiB
Python

"""
Immersive environments for depth3d_vr: 360/180 degree images and videos,
mono or stereo (side-by-side / over-under), shown on a big sphere around you.
Drop files into the `environments` folder next to the script. Name hints
(optional, the in-VR menu can also cycle the layout):
"..._180_sbs.mp4" VR180 side-by-side "..._360_ou.jpg" 360 over-under
"..._360.jpg" 360 mono "..._tb", "_lr", "_3dh", "_3dv" also work
"""
import re
import threading
import time
from pathlib import Path
import moderngl
import numpy as np
from PIL import Image
from vr_shaders import ENV_FRAG, ENV_VERT
Image.MAX_IMAGE_PIXELS = None
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
VIDEO_EXT = {".mp4", ".mkv", ".webm", ".mov", ".m4v", ".avi"}
LAYOUTS = ["360 mono", "360 OU", "360 SBS", "180 mono", "180 SBS", "180 OU"]
MAX_IMAGE_DIM = 8192
MAX_VIDEO_WIDTH = 4096
def scan(folder):
folder = Path(folder)
if not folder.is_dir():
return []
files = [p for p in folder.iterdir() if p.suffix.lower() in IMAGE_EXT | VIDEO_EXT]
return sorted(files, key=lambda p: p.name.lower())
def _tag(name, *words):
pat = r"(^|[^a-z0-9])(" + "|".join(words) + r")([^a-z0-9]|$)"
return re.search(pat, name) is not None
def guess_layout(name, w, h):
"""Best-effort layout from the file name, falling back to the aspect ratio."""
n = name.lower()
fov = 180 if "180" in n else (360 if "360" in n else None)
if _tag(n, "sbs", "lr", "3dh", "lr180", "sbs180"):
stereo = "SBS"
elif _tag(n, "ou", "tb", "3dv", "overunder", "over_under", "topbottom"):
stereo = "OU"
elif _tag(n, "mono"):
stereo = "mono"
else:
stereo = None
a = w / max(h, 1)
if fov is None:
fov = 360
if stereo is None:
if fov == 180:
stereo = "SBS" if a > 1.5 else "mono"
else:
stereo = "OU" if 0.75 < a < 1.35 else ("SBS" if a > 3.0 else "mono")
return f"{fov} {stereo}"
def parse_layout(layout):
fov180 = 1 if layout.startswith("180") else 0
stereo = 1 if "SBS" in layout else (2 if "OU" in layout else 0)
return fov180, stereo
def build_sphere(lat_n=48, lon_n=96):
verts = []
for i in range(lat_n + 1):
lat = np.pi * (i / lat_n - 0.5)
for j in range(lon_n + 1):
lon = 2 * np.pi * (j / lon_n - 0.5)
verts.append((np.cos(lat) * np.sin(lon), np.sin(lat), -np.cos(lat) * np.cos(lon)))
idx = []
for i in range(lat_n):
for j in range(lon_n):
a = i * (lon_n + 1) + j
b = a + lon_n + 1
idx += [a, b, a + 1, a + 1, b, b + 1]
return np.array(verts, "f4"), np.array(idx, "i4")
class EnvManager:
def __init__(self, ctx, folder):
self.ctx = ctx
self.folder = Path(folder)
self.folder.mkdir(exist_ok=True)
self.files = scan(self.folder)
self.prog = ctx.program(vertex_shader=ENV_VERT, fragment_shader=ENV_FRAG)
v, i = build_sphere()
self.vao = ctx.vertex_array(self.prog, [(ctx.buffer(v.tobytes()), "3f", "pos")],
index_buffer=ctx.buffer(i.tobytes()))
self.prog["env_tex"].value = 3
self.tex = None
self.size = None
self.name = ""
self.is_video = False
self.guessed = "360 mono"
self.loading = False
self.error = ""
self.paused = False
self._lock = threading.Lock()
self._pending = None
self._pending_info = None
self._gen = 0
# ---------------------------------------------------------------- selection
def names(self):
return [""] + [p.name for p in self.files]
def rescan(self):
self.files = scan(self.folder)
def select(self, name):
"""Start loading `name` ('' = no environment). Loading happens on a worker thread."""
self._gen += 1
gen = self._gen
self.error = ""
with self._lock:
self._pending = None
if not name:
self.name = ""
self.loading = False
self.is_video = False
self._release()
return
path = self.folder / name
if not path.exists():
self.name = ""
self.error = f"{name} not found"
return
self.name = name
self.loading = True
self.is_video = path.suffix.lower() in VIDEO_EXT
target = self._video_loop if self.is_video else self._load_image
threading.Thread(target=target, args=(path, gen), daemon=True).start()
def _release(self):
if self.tex is not None:
self.tex.release()
self.tex = None
self.size = None
# ---------------------------------------------------------------- worker threads
def _load_image(self, path, gen):
try:
img = Image.open(path)
img.load()
img = img.convert("RGB")
w, h = img.size
s = min(1.0, MAX_IMAGE_DIM / max(w, h))
if s < 1.0:
img = img.resize((max(1, int(w * s)), max(1, int(h * s))), Image.LANCZOS)
arr = np.ascontiguousarray(np.asarray(img))
with self._lock:
if gen == self._gen:
self._pending = arr
except Exception as e: # noqa: BLE001
if gen == self._gen:
self.error = f"Could not load: {e}"
self.loading = False
def _video_loop(self, path, gen):
try:
import av
except ImportError:
self.error = "Videos need the 'av' package (run setup.bat)"
self.loading = False
return
try:
while gen == self._gen:
container = av.open(str(path))
stream = container.streams.video[0]
stream.thread_type = "AUTO"
fps = float(stream.average_rate or 30)
t0, n = time.time(), 0
for frame in container.decode(stream):
if gen != self._gen:
break
while self.paused and gen == self._gen:
time.sleep(0.05)
t0 = time.time() - n / fps
w, h = frame.width, frame.height
s = min(1.0, MAX_VIDEO_WIDTH / w)
arr = frame.reformat(width=int(w * s) // 2 * 2, height=int(h * s) // 2 * 2,
format="rgb24").to_ndarray()
with self._lock:
if gen == self._gen:
self._pending = arr
n += 1
delay = t0 + n / fps - time.time()
if delay > 0:
time.sleep(delay)
elif delay < -1.0: # fell far behind: resync, don't spiral
t0, n = time.time(), 0
container.close()
except Exception as e: # noqa: BLE001
if gen == self._gen:
self.error = f"Video error: {e}"
self.loading = False
# ---------------------------------------------------------------- GL thread
def update(self):
"""Upload a newly decoded image / video frame (call once per VR frame)."""
with self._lock:
arr, self._pending = self._pending, None
if arr is None:
return
h, w = arr.shape[:2]
if self.size != (w, h):
self._release()
self.tex = self.ctx.texture((w, h), 3, dtype="f1")
self.tex.filter = (moderngl.LINEAR_MIPMAP_LINEAR, moderngl.LINEAR)
self.tex.repeat_x = True
self.tex.repeat_y = False
self.tex.anisotropy = 8.0
self.size = (w, h)
self.guessed = guess_layout(self.name, w, h)
self.tex.write(arr)
self.tex.build_mipmaps()
self.loading = False
@property
def ready(self):
return self.tex is not None and bool(self.name)
def draw(self, mvp, eye, layout, yaw, brightness):
fov180, stereo = parse_layout(layout)
self.tex.use(3)
self.prog["mvp"].write(mvp.T.astype("f4").tobytes())
self.prog["stereo"].value = stereo
self.prog["fov180"].value = fov180
self.prog["eye"].value = eye
self.prog["yaw"].value = yaw
self.prog["brightness"].value = brightness
self.vao.render()
def stop(self):
self._gen += 1