PaGeR-showcase / app.py
ApacheOne's picture
Update app.py
a594a6f verified
Raw
History Blame Contribute Delete
24.6 kB
import os
import shutil
import html
import logging
import numpy as np
import gradio as gr
# Setup professional logging
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s - %(message)s")
logger = logging.getLogger("PaGeR-Showcase")
# Setup static folders
assets_dir = "assets"
os.makedirs(assets_dir, exist_ok=True)
# Create fallback simulation room point cloud if none exists
def generate_fallback_ply(filepath):
"""
Generates a beautiful 3D test room shell (walls, floor, ceiling)
with a red sphere and green sphere. Approximately 110,000 colored points.
"""
logger.info(f"Generating fallback point cloud at: {filepath}")
points = []
num_points_wall = 15000
# Floor (y = -2.5)
for _ in range(num_points_wall):
x = np.random.uniform(-5, 5)
z = np.random.uniform(-5, 5)
y = -2.5
r = int((x + 5) * 25.5)
g = 45
b = int((z + 5) * 25.5)
points.append(f"{x} {y} {z} {r} {g} {b}")
# Ceiling (y = 2.5)
for _ in range(num_points_wall):
x = np.random.uniform(-5, 5)
z = np.random.uniform(-5, 5)
y = 2.5
r = 45
g = int((x + 5) * 25.5)
b = int((z + 5) * 25.5)
points.append(f"{x} {y} {z} {r} {g} {b}")
# 4 Walls (x = -5, x = 5, z = -5, z = 5)
for _ in range(num_points_wall * 4):
wall = np.random.choice([0, 1, 2, 3])
y = np.random.uniform(-2.5, 2.5)
if wall == 0: # back wall z = -5
x = np.random.uniform(-5, 5)
z = -5
r = 100
g = int((y + 2.5) * 51)
b = int((x + 5) * 25.5)
elif wall == 1: # front wall z = 5
x = np.random.uniform(-5, 5)
z = 5
r = int((x + 5) * 25.5)
g = 100
b = int((y + 2.5) * 51)
elif wall == 2: # left wall x = -5
x = -5
z = np.random.uniform(-5, 5)
r = int((z + 5) * 25.5)
g = int((y + 2.5) * 51)
b = 100
else: # right wall x = 5
x = 5
z = np.random.uniform(-5, 5)
r = int((y + 2.5) * 51)
g = int((z + 5) * 25.5)
b = 150
points.append(f"{x} {y} {z} {r} {g} {b}")
# Decorative Interior Sphere 1 (Red)
for _ in range(20000):
u = np.random.random()
v = np.random.random()
theta = u * 2.0 * np.pi
phi = np.arccos(2.0 * v - 1.0)
r = 1.0
x = 1.8 + r * np.sin(phi) * np.cos(theta)
y = -1.2 + r * np.sin(phi) * np.sin(theta)
z = 1.8 + r * np.cos(phi)
points.append(f"{x} {y} {z} 239 68 68")
# Decorative Interior Sphere 2 (Green)
for _ in range(20000):
u = np.random.random()
v = np.random.random()
theta = u * 2.0 * np.pi
phi = np.arccos(2.0 * v - 1.0)
r = 1.2
x = -2.2 + r * np.sin(phi) * np.cos(theta)
y = -0.8 + r * np.sin(phi) * np.sin(theta)
z = -2.2 + r * np.cos(phi)
points.append(f"{x} {y} {z} 34 197 94")
# Construct ASCII PLY format header
header = f"""ply
format ascii 1.0
element vertex {len(points)}
property float x
property float y
property float z
property uchar red
property uchar green
property uchar blue
end_header
"""
with open(filepath, "w") as f:
f.write(header + "\n".join(points))
logger.info(f"Fallback point cloud generated successfully with {len(points)} points.")
# Pre-generate preview1 if missing
preview_ply = os.path.join(assets_dir, "preview1.ply")
if not os.path.exists(preview_ply):
generate_fallback_ply(preview_ply)
def get_ply_files():
"""Scans the assets directory and returns a sorted list of PLY point cloud files."""
files = [f for f in os.listdir(assets_dir) if f.endswith(".ply")]
logger.info(f"Scanning 'assets/' folder. Found PLY files: {files}")
if not files:
return []
return sorted([os.path.join(assets_dir, f) for f in files])
def get_viewer_html(ply_url):
"""Generates the embedded HTML template for the ThreeJS WebGL view."""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Point Cloud Viewer</title>
<style>
* {{ box-sizing: border-box; }}
body, html {{
margin: 0; padding: 0; width: 100%; height: 100%;
overflow: hidden; background-color: #0b0f19;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
user-select: none;
}}
#canvas-container {{ width: 100%; height: 100%; position: relative; }}
#three-canvas {{ width: 100%; height: 100%; cursor: grab; }}
#three-canvas:active {{ cursor: grabbing; }}
.overlay {{
position: absolute;
background: rgba(15, 23, 42, 0.85);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
border-radius: 8px;
padding: 14px;
z-index: 10;
}}
.controls-left {{
top: 15px; left: 15px;
max-width: 260px;
pointer-events: none;
}}
.controls-right {{
bottom: 15px; right: 15px;
width: 250px;
display: flex; flex-direction: column; gap: 12px;
}}
h3 {{
margin: 0 0 8px 0; font-size: 11px; color: #94a3b8;
text-transform: uppercase; letter-spacing: 0.05em;
}}
.instruction-row {{
font-size: 11px; display: flex; align-items: center; gap: 8px; margin-bottom: 6px; color: #cbd5e1;
}}
.key-badge {{
background: rgba(255,255,255,0.15); border-radius: 4px; padding: 2px 6px;
font-family: monospace; font-size: 10px; border: 1px solid rgba(255,255,255,0.2);
color: #3b82f6; font-weight: bold;
}}
.slider-group {{ display: flex; flex-direction: column; gap: 4px; }}
.slider-group label {{ font-size: 11px; color: #cbd5e1; display: flex; justify-content: space-between; }}
.slider-group input[type="range"] {{
width: 100%; height: 4px; border-radius: 2px; outline: none; background: rgba(255,255,255,0.2); -webkit-appearance: none;
}}
.slider-group input[type="range"]::-webkit-slider-thumb {{
-webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #3b82f6; cursor: pointer;
}}
.btn {{
background: #3b82f6; border: none; color: white; padding: 8px 10px; border-radius: 4px;
font-size: 11px; cursor: pointer; transition: background 0.2s; font-weight: 600;
text-transform: uppercase; width: 100%; letter-spacing: 0.05em;
}}
.btn:hover {{ background: #2563eb; }}
.stats-row {{
border-top: 1px solid rgba(255,255,255,0.1); padding-top: 8px; font-size: 10px; color: #94a3b8; display: flex; justify-content: space-between;
}}
#loading-overlay {{
position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: #0b0f19;
display: flex; flex-direction: column; justify-content: center; align-items: center;
z-index: 100; transition: opacity 0.4s ease;
}}
.spinner {{
width: 40px; height: 40px; border: 3px solid rgba(255,255,255,0.1); border-radius: 50%;
border-top-color: #3b82f6; animation: spin 0.8s linear infinite;
}}
#loading-text {{ color: #e2e8f0; font-size: 13px; margin-top: 15px; font-weight: 500; }}
#loading-progress {{ color: #64748b; font-size: 11px; margin-top: 4px; }}
@keyframes spin {{ 0% {{ transform: rotate(0deg); }} 100% {{ transform: rotate(360deg); }} }}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/PLYLoader.js"></script>
</head>
<body>
<div id="canvas-container">
<div id="three-canvas"></div>
<div id="loading-overlay">
<div class="spinner"></div>
<div id="loading-text">Loading 3D Point Cloud...</div>
<div id="loading-progress">0%</div>
</div>
<div class="overlay controls-left">
<h3>Navigation</h3>
<div class="instruction-row"><span class="key-badge">Drag Mouse</span> Look Around</div>
<div class="instruction-row"><span class="key-badge">W, A, S, D</span> Fly / Move Ground</div>
<div class="instruction-row"><span class="key-badge">SPACE</span> / <span class="key-badge">E</span> Move UP</div>
<div class="instruction-row"><span class="key-badge">SHIFT</span> / <span class="key-badge">Q</span> Move DOWN</div>
</div>
<div class="overlay controls-right">
<h3>Settings</h3>
<div class="slider-group">
<label><span>Point Size</span><span id="val-point-size">0.02</span></label>
<input id="slider-point-size" type="range" min="0.001" max="0.100" step="0.001" value="0.020">
</div>
<div class="slider-group">
<label><span>Movement Speed</span><span id="val-speed">0.05</span></label>
<input id="slider-speed" type="range" min="0.01" max="0.40" step="0.01" value="0.05">
</div>
<button id="btn-reset" class="btn">Reset to Center</button>
<div class="stats-row">
<span>Points: <span id="stat-points">-</span></span>
<span>FPS: <span id="stat-fps">0</span></span>
</div>
</div>
</div>
<script>
let pointSize = 0.02;
let flightSpeed = 0.05;
let cloudCenter = new THREE.Vector3(0, 0, 0);
let scene, camera, renderer, pointsObject;
let lat = 0, lon = 0;
let isDragging = false;
let startX, startY, startLon, startLat;
const keys = {{}};
const canvasContainer = document.getElementById('canvas-container');
const pointSizeSlider = document.getElementById('slider-point-size');
const pointSizeVal = document.getElementById('val-point-size');
const speedSlider = document.getElementById('slider-speed');
const speedVal = document.getElementById('val-speed');
const resetBtn = document.getElementById('btn-reset');
const statPoints = document.getElementById('stat-points');
const statFps = document.getElementById('stat-fps');
const loadingOverlay = document.getElementById('loading-overlay');
const loadingProgress = document.getElementById('loading-progress');
let lastTime = performance.now();
let frameCount = 0;
function init() {{
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0f19);
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.01, 1000);
camera.position.set(0, 0, 0);
renderer = new THREE.WebGLRenderer({{ antialias: true, powerPreference: "high-performance" }});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.getElementById('three-canvas').appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize);
renderer.domElement.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
renderer.domElement.addEventListener('mouseenter', () => {{ window.focus(); }});
window.addEventListener('keydown', (e) => {{ keys[e.key.toLowerCase()] = true; }});
window.addEventListener('keyup', (e) => {{ keys[e.key.toLowerCase()] = false; }});
pointSizeSlider.addEventListener('input', (e) => {{
pointSize = parseFloat(e.target.value);
pointSizeVal.textContent = pointSize.toFixed(3);
if (pointsObject) pointsObject.material.size = pointSize;
}});
speedSlider.addEventListener('input', (e) => {{
flightSpeed = parseFloat(e.target.value);
speedVal.textContent = flightSpeed.toFixed(2);
}});
resetBtn.addEventListener('click', () => {{
camera.position.copy(cloudCenter);
lat = 0;
lon = 0;
}});
loadPointCloud();
animate();
}}
function loadPointCloud() {{
const loader = new THREE.PLYLoader();
const plyFile = "{ply_url}";
console.log("Attempting to load point cloud from URL:", plyFile);
loader.load(
plyFile,
function (geometry) {{
console.log("Point cloud parsed successfully!");
const hasColors = geometry.hasAttribute('color');
const material = new THREE.PointsMaterial({{
size: pointSize,
vertexColors: hasColors,
sizeAttenuation: true
}});
if (!hasColors) {{
material.color = new THREE.Color(0x3b82f6);
}}
pointsObject = new THREE.Points(geometry, material);
scene.add(pointsObject);
const count = geometry.attributes.position.count;
statPoints.textContent = count.toLocaleString();
geometry.computeBoundingBox();
const box = geometry.boundingBox;
box.getCenter(cloudCenter);
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim > 50) {{
pointSize = maxDim * 0.001;
flightSpeed = maxDim * 0.005;
}} else if (maxDim > 5) {{
pointSize = maxDim * 0.005;
flightSpeed = maxDim * 0.01;
}} else {{
pointSize = 0.015;
flightSpeed = 0.04;
}}
pointSizeSlider.min = (pointSize * 0.05).toFixed(4);
pointSizeSlider.max = (pointSize * 15.0).toFixed(4);
pointSizeSlider.value = pointSize;
pointSizeVal.textContent = pointSize.toFixed(3);
speedSlider.min = (flightSpeed * 0.1).toFixed(3);
speedSlider.max = (flightSpeed * 8.0).toFixed(3);
speedSlider.value = flightSpeed;
speedVal.textContent = flightSpeed.toFixed(2);
if (pointsObject) pointsObject.material.size = pointSize;
// Center the camera inside the middle POV of the bounding box
camera.position.copy(cloudCenter);
loadingOverlay.style.opacity = 0;
setTimeout(() => {{ loadingOverlay.style.display = 'none'; }}, 400);
}},
function (xhr) {{
if (xhr.total) {{
const percent = Math.round((xhr.loaded / xhr.total) * 100);
loadingProgress.textContent = percent + '%';
}} else {{
loadingProgress.textContent = (xhr.loaded / (1024 * 1024)).toFixed(1) + ' MB';
}}
}},
function (error) {{
console.error("Loader Error Detailed Log:", error);
document.getElementById('loading-text').textContent = 'Error loading point cloud file.';
loadingProgress.textContent = 'Please make sure that the PLY file is placed in the assets/ directory.';
}}
);
}}
function onWindowResize() {{
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}}
function onMouseDown(e) {{
isDragging = true;
startX = e.clientX;
startY = e.clientY;
startLon = lon;
startLat = lat;
}}
function onMouseMove(e) {{
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
lon = startLon - dx * 0.15;
lat = startLat + dy * 0.15;
}}
function onMouseUp() {{
isDragging = false;
}}
function animate() {{
requestAnimationFrame(animate);
lat = Math.max(-85, Math.min(85, lat));
const phi = THREE.MathUtils.degToRad(90 - lat);
const theta = THREE.MathUtils.degToRad(lon);
const target = new THREE.Vector3();
target.x = camera.position.x + Math.sin(phi) * Math.cos(theta);
target.y = camera.position.y + Math.cos(phi);
target.z = camera.position.z + Math.sin(phi) * Math.sin(theta);
camera.lookAt(target);
const forward = new THREE.Vector3();
camera.getWorldDirection(forward);
forward.y = 0;
if (forward.lengthSq() < 0.001) {{
forward.set(Math.sin(THREE.MathUtils.degToRad(lon)), 0, Math.cos(THREE.MathUtils.degToRad(lon)));
}}
forward.normalize();
const right = new THREE.Vector3();
right.crossVectors(camera.up, forward).normalize();
if (keys['w'] || keys['arrowup']) {{
camera.position.addScaledVector(forward, flightSpeed);
}}
if (keys['s'] || keys['arrowdown']) {{
camera.position.addScaledVector(forward, -flightSpeed);
}}
if (keys['a'] || keys['arrowleft']) {{
camera.position.addScaledVector(right, flightSpeed);
}}
if (keys['d'] || keys['arrowright']) {{
camera.position.addScaledVector(right, -flightSpeed);
}}
if (keys[' '] || keys['e']) {{
camera.position.y += flightSpeed;
}}
if (keys['shift'] || keys['q']) {{
camera.position.y -= flightSpeed;
}}
renderer.render(scene, camera);
frameCount++;
const time = performance.now();
if (time >= lastTime + 1000) {{
statFps.textContent = Math.round((frameCount * 1000) / (time - lastTime));
frameCount = 0;
lastTime = time;
}}
}}
window.onload = init;
</script>
</body>
</html>"""
def update_viewer_iframe(selected_file):
"""
Constructs a responsive iframe utilizing a secure inline HTML source.
Also handles route mappings based on the Gradio major version.
"""
if not selected_file:
logger.warning("No file selected in update_viewer_iframe!")
return "No file selected."
# Map to the absolute path on the host, correcting cross-platform path separators
abs_ply_path = os.path.abspath(selected_file).replace("\\", "/")
# Check the Gradio version to decide correct file route mapping
gradio_major_version = int(gr.__version__.split(".")[0])
if gradio_major_version >= 5:
# Gradio 5 and 6 require /gradio_api/ prefix for custom assets
ply_url = f"/gradio_api/file={abs_ply_path}"
else:
# Legacy fallback
ply_url = f"/file={abs_ply_path}"
logger.info(f"Target file: '{selected_file}'")
logger.info(f"Resolved path: '{abs_ply_path}'")
logger.info(f"Gradio Major Version: {gradio_major_version}. Generated URL mapping: '{ply_url}'")
raw_html = get_viewer_html(ply_url)
escaped_html = html.escape(raw_html)
return f'<iframe srcdoc="{escaped_html}" style="width:100%; height:750px; border:none; border-radius:12px; background:#0b0f19; box-shadow: 0 4px 20px rgba(0,0,0,0.45);"></iframe>'
def handle_file_upload(file_obj):
"""Saves user-uploaded .ply files to assets/ and selects them."""
if file_obj is None:
return gr.update()
dest_filename = os.path.basename(file_obj.name)
dest_path = os.path.join(assets_dir, dest_filename)
logger.info(f"User uploaded file. Storing to: {dest_path}")
shutil.copy(file_obj.name, dest_path)
updated_files = get_ply_files()
return gr.update(choices=updated_files, value=dest_path)
def refresh_file_list():
"""Manual scan update for files placed into assets/ via git or CLI."""
logger.info("Manual assets/ directory scan triggered.")
updated_files = get_ply_files()
default_val = updated_files[0] if updated_files else None
return gr.update(choices=updated_files, value=default_val)
# Handle theme configuration based on Gradio version
theme = gr.themes.Default(
primary_hue="blue",
neutral_hue="slate",
).set(
body_background_fill="*neutral_950",
block_background_fill="*neutral_900",
block_border_color="*neutral_800",
button_secondary_background_fill="*neutral_800"
)
gradio_major_version = int(gr.__version__.split(".")[0])
logger.info(f"Starting application layout. Detected Gradio Major: {gradio_major_version}")
blocks_kwargs = {}
launch_kwargs = {
"allowed_paths": [os.path.abspath(assets_dir)]
}
# Resolve the Gradio 6 theme warning by adjusting kwarg targets
if gradio_major_version >= 6:
launch_kwargs["theme"] = theme
else:
blocks_kwargs["theme"] = theme
with gr.Blocks(title="PaGeR Point Cloud Showcase", **blocks_kwargs) as demo:
gr.Markdown(
"""
# ๐Ÿช Panoramic Point Cloud Viewer
Load and navigate large panoramic point cloud files (up to **1,000,000+ points**). The camera places you directly in the **middle POV** on load, rendering optimal spatial depth.
"""
)
with gr.Row():
with gr.Column(scale=3):
file_dropdown = gr.Dropdown(
choices=get_ply_files(),
value=get_ply_files()[0] if get_ply_files() else None,
label="๐Ÿ“ Select Point Cloud File",
interactive=True,
)
with gr.Column(scale=1):
refresh_btn = gr.Button("๐Ÿ”„ Scan assets/", size="sm")
with gr.Column(scale=2):
upload_zone = gr.File(
label="๐Ÿ“ค Drag & Drop New .PLY File Here",
file_types=[".ply"],
type="filepath",
height=80
)
# Core interactive WebGL viewport component
viewer_component = gr.HTML(
value=update_viewer_iframe(file_dropdown.value) if file_dropdown.value else ""
)
# Event routing
file_dropdown.change(
fn=update_viewer_iframe,
inputs=file_dropdown,
outputs=viewer_component
)
upload_zone.upload(
fn=handle_file_upload,
inputs=upload_zone,
outputs=file_dropdown
)
refresh_btn.click(
fn=refresh_file_list,
inputs=[],
outputs=file_dropdown
)
# Static asset paths for custom resource streaming
gr.set_static_paths(paths=[assets_dir])
if __name__ == "__main__":
logger.info(f"Launching Gradio with arguments: {launch_kwargs}")
demo.launch(**launch_kwargs)