Spaces:
Runtime error
Runtime error
File size: 5,815 Bytes
a0e2b41 0de805c a0e2b41 08572f5 a0e2b41 08572f5 a0e2b41 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | """
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>
"""
|