evoneuralIn3D-app / scripts /panorama_viewer.py
manav0506's picture
Sync deps: Dockerfile system deps + ffmpeg, single pip flow
08572f5
Raw
History Blame Contribute Delete
5.82 kB
"""
360° panorama viewer for equirectangular (2:1) skybox images.
Returns HTML for use with st.components.v1.html().
Resizes image to keep data URL small so it loads reliably in iframes.
Fetches Pannellum script server-side and inlines it so the client does not load from CDN (avoids CSP/network block).
"""
import base64
import io
import urllib.request
from pathlib import Path
# Max width for viewer image (keeps data URL under ~1MB for reliable loading)
MAX_VIEWER_WIDTH = 1024
# CDN URLs for Pannellum (fetched server-side and inlined)
PANNELLUM_JS_URL = "https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js"
PANNELLUM_CSS_URL = "https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css"
# Optional local fallback (if CDN is blocked on server)
_SCRIPT_DIR = Path(__file__).resolve().parent
_PANNELLUM_ASSETS = _SCRIPT_DIR / "panorama_assets"
def _resize_and_encode(image_path: Path) -> tuple[str, str]:
"""Load image, resize to max width (keep 2:1), return (data_url, mime)."""
from PIL import Image
img = Image.open(image_path).convert("RGB")
w, h = img.size
if w > MAX_VIEWER_WIDTH:
new_w = MAX_VIEWER_WIDTH
new_h = max(256, (new_w * h) // w)
img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
return f"data:image/jpeg;base64,{b64}", "image/jpeg"
def _fetch_pannellum_assets() -> tuple[str | None, str | None]:
"""Fetch or read Pannellum JS and CSS. Returns (js_content, css_content) or (None, None) on failure."""
js_content, css_content = None, None
# Try local assets first (no network)
js_file = _PANNELLUM_ASSETS / "pannellum.js"
css_file = _PANNELLUM_ASSETS / "pannellum.css"
if js_file.exists() and css_file.exists():
js_content = js_file.read_text(encoding="utf-8", errors="replace")
css_content = css_file.read_text(encoding="utf-8", errors="replace")
return js_content, css_content
# Fetch from CDN (server-side)
try:
req = urllib.request.Request(PANNELLUM_JS_URL, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=10) as r:
js_content = r.read().decode("utf-8", errors="replace")
except Exception:
js_content = None
try:
req = urllib.request.Request(PANNELLUM_CSS_URL, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=10) as r:
css_content = r.read().decode("utf-8", errors="replace")
except Exception:
css_content = None
return js_content, css_content
def image_to_data_url(image_path: str | Path) -> str:
"""Read image file, resize if needed, return a data URL (base64)."""
path = Path(image_path)
if not path.exists():
return ""
data_url, _ = _resize_and_encode(path)
return data_url
def panorama_html(
image_path: str | Path,
height_px: int = 480,
full_page_background: bool = False,
) -> str:
"""
Build HTML for an interactive 360° panorama viewer (Pannellum).
image_path: path to equirectangular 2:1 image (e.g. skybox PNG).
height_px: viewer height in pixels (ignored if full_page_background=True).
full_page_background: if True, viewer fills 100% of container (use as page background).
Pannellum JS/CSS are fetched server-side and inlined so the client does not load from CDN.
"""
path = Path(image_path)
if not path.exists():
return f'<p style="padding:1em;color:#888;">Image not found: {path.name}</p>'
if not path.is_file():
return f'<p style="padding:1em;color:#888;">Not a file: {path.name}</p>'
js_content, css_content = _fetch_pannellum_assets()
if not js_content or not css_content:
return (
'<p style="padding:1em;color:#c66;">Viewer unavailable: could not load Pannellum. '
"Check network or add scripts/panorama_assets/pannellum.js and pannellum.css.</p>"
)
try:
data_url, _ = _resize_and_encode(path)
except Exception:
return '<p style="padding:1em;color:#c66;">Could not load image (corrupted or invalid format).</p>'
data_url_escaped = data_url.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "")
# Inline script: escape </script> so it does not close our tag
js_safe = js_content.replace("</script>", "<\\/script>")
if full_page_background:
size_style = "html, body { margin: 0; padding: 0; width: 100%; height: 100%; }\n #panorama { width: 100%; height: 100%; min-height: 100vh; }"
else:
size_style = f"body {{ margin: 0; }}\n #panorama {{ width: 100%; height: {height_px}px; }}"
return f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
{size_style}
.pnlm-container {{ border-radius: 0; }}
.pnlm-error {{ color: #ccc; padding: 1em; }}
</style>
<style>{css_content}</style>
</head>
<body>
<div id="panorama"></div>
<script>{js_safe}</script>
<script>
(function() {{
var panoramaUrl = "{data_url_escaped}";
try {{
pannellum.viewer('panorama', {{
type: 'equirectangular',
panorama: panoramaUrl,
autoLoad: true,
showControls: true,
compass: true,
mouseZoom: true,
draggable: true,
showZoomCtrl: true,
showFullscreenCtrl: true,
hfov: 100,
minHfov: 50,
maxHfov: 120
}});
}} catch (e) {{
document.getElementById('panorama').innerHTML = '<p class="pnlm-error">Viewer error: ' + e.message + '</p>';
}}
}})();
</script>
</body>
</html>
"""