Spaces:
Running on Zero
Running on Zero
| """Browser-based 2D viewer: drag-to-apply force on mouse release.""" | |
| import io | |
| import json | |
| import mimetypes | |
| import os | |
| import threading | |
| import time | |
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| from urllib.parse import parse_qs, urlparse | |
| import numpy as np | |
| import torch | |
| from PIL import Image | |
| from utils.interactive_scenes import scene_public_view | |
| _FORCE_MIN = 1.0 | |
| _FORCE_MAX = 10.0 | |
| _INDEX_HTML = """<!DOCTYPE html> | |
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>EndoGSim Interactive / 交互仿真</title> | |
| <style> | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| font-family: system-ui, sans-serif; | |
| background: #111; | |
| color: #eee; | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| header { | |
| padding: 0.4rem 0.75rem; | |
| background: #1a1a1a; | |
| border-bottom: 1px solid #333; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.5rem 1rem; | |
| align-items: center; | |
| flex-shrink: 0; | |
| } | |
| header label { display: flex; align-items: center; gap: 0.4rem; } | |
| header button { | |
| background: #2d6cdf; | |
| color: #fff; | |
| border: none; | |
| border-radius: 4px; | |
| padding: 0.35rem 0.75rem; | |
| cursor: pointer; | |
| } | |
| header button:hover { background: #3a7af0; } | |
| header button:disabled { opacity: 0.5; cursor: not-allowed; } | |
| #status { margin-left: auto; font-size: 0.9rem; color: #aaa; max-width: 40%; text-align: right; } | |
| #scene-bar { | |
| flex-shrink: 0; | |
| background: #151515; | |
| border-bottom: 1px solid #333; | |
| padding: 0.45rem 0.6rem; | |
| display: flex; | |
| align-items: stretch; | |
| gap: 0.6rem; | |
| overflow: hidden; | |
| } | |
| #scene-bar .scene-bar-label { | |
| flex-shrink: 0; | |
| font-size: 0.85rem; | |
| font-weight: 600; | |
| color: #aaa; | |
| align-self: center; | |
| padding-right: 0.15rem; | |
| white-space: nowrap; | |
| } | |
| #scene-list { | |
| display: flex; | |
| flex-direction: row; | |
| gap: 0.55rem; | |
| overflow-x: auto; | |
| overflow-y: hidden; | |
| flex: 1; | |
| min-width: 0; | |
| padding-bottom: 0.15rem; | |
| scrollbar-width: thin; | |
| scrollbar-color: #444 #1a1a1a; | |
| } | |
| #scene-list::-webkit-scrollbar { height: 6px; } | |
| #scene-list::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; } | |
| .scene-card { | |
| display: flex; | |
| flex-direction: column; | |
| flex: 0 0 148px; | |
| width: 148px; | |
| text-align: left; | |
| background: #1e1e1e; | |
| border: 2px solid #333; | |
| border-radius: 8px; | |
| padding: 0; | |
| cursor: pointer; | |
| color: inherit; | |
| overflow: hidden; | |
| transition: border-color 0.15s, background 0.15s; | |
| } | |
| .scene-card:hover { border-color: #555; background: #252525; } | |
| .scene-card.active { border-color: #2d6cdf; background: #1a2744; } | |
| .scene-card:disabled { opacity: 0.55; cursor: wait; } | |
| .scene-card img { | |
| display: block; | |
| width: 100%; | |
| height: 72px; | |
| object-fit: cover; | |
| background: #000; | |
| } | |
| .scene-card .scene-text { padding: 0.35rem 0.45rem 0.45rem; } | |
| .scene-card .scene-name { | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| line-height: 1.25; | |
| margin-bottom: 0.15rem; | |
| display: -webkit-box; | |
| -webkit-line-clamp: 2; | |
| -webkit-box-orient: vertical; | |
| overflow: hidden; | |
| } | |
| .scene-card .scene-desc { | |
| font-size: 0.68rem; | |
| color: #888; | |
| line-height: 1.25; | |
| display: -webkit-box; | |
| -webkit-line-clamp: 2; | |
| -webkit-box-orient: vertical; | |
| overflow: hidden; | |
| } | |
| #main-panel { | |
| flex: 1; | |
| min-height: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 0.25rem; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| #view-wrap { | |
| position: relative; | |
| line-height: 0; | |
| touch-action: none; | |
| user-select: none; | |
| cursor: crosshair; | |
| } | |
| #frame { | |
| display: block; | |
| width: auto; | |
| height: auto; | |
| border: 1px solid #333; | |
| pointer-events: none; | |
| image-rendering: auto; | |
| } | |
| #overlay { | |
| position: absolute; | |
| left: 0; | |
| top: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: auto; | |
| } | |
| #overlay.disabled { pointer-events: none; cursor: wait; } | |
| #loading-mask { | |
| display: none; | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(0,0,0,0.55); | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 1.1rem; | |
| color: #ddd; | |
| z-index: 10; | |
| } | |
| #loading-mask.show { display: flex; } | |
| .hint { font-size: 0.85rem; color: #888; } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <label><input type="checkbox" id="paused" /> | |
| <span>Paused / 暂停</span></label> | |
| <button type="button" id="reset">Reset / 重置</button> | |
| <label><span>Force / 力度</span> | |
| <input type="range" id="force" min="1" max="10" step="0.1" value="1" /> | |
| <span id="force-val">1.0</span> | |
| </label> | |
| <span class="hint">Drag & release to push / 拖动后松开施力</span> | |
| <span id="status">Ready / 就绪</span> | |
| </header> | |
| <div id="scene-bar"> | |
| <span class="scene-bar-label">Scenes / 场景</span> | |
| <div id="scene-list"></div> | |
| </div> | |
| <div id="main-panel"> | |
| <div id="loading-mask">Loading scene / 加载场景中…</div> | |
| <div id="view-wrap"> | |
| <img id="frame" alt="Simulation render / 仿真画面" draggable="false" /> | |
| <canvas id="overlay"></canvas> | |
| </div> | |
| </div> | |
| <script> | |
| const viewWrap = document.getElementById("view-wrap"); | |
| const mainPanel = document.getElementById("main-panel"); | |
| const frame = document.getElementById("frame"); | |
| const overlay = document.getElementById("overlay"); | |
| const loadingMask = document.getElementById("loading-mask"); | |
| const sceneList = document.getElementById("scene-list"); | |
| const ctx = overlay.getContext("2d"); | |
| const statusEl = document.getElementById("status"); | |
| const pausedCb = document.getElementById("paused"); | |
| const resetBtn = document.getElementById("reset"); | |
| const forceSlider = document.getElementById("force"); | |
| const forceVal = document.getElementById("force-val"); | |
| const headerEl = document.querySelector("header"); | |
| const sceneBarEl = document.getElementById("scene-bar"); | |
| let dragging = false; | |
| let startX = 0; | |
| let startY = 0; | |
| let curX = 0; | |
| let curY = 0; | |
| let currentSceneId = null; | |
| let sceneLoading = false; | |
| let wasServerLoading = false; | |
| let lastFrameVersion = -1; | |
| function setSceneLoading(loading) { | |
| sceneLoading = loading; | |
| loadingMask.classList.toggle("show", loading); | |
| overlay.classList.toggle("disabled", loading); | |
| document.querySelectorAll(".scene-card").forEach((btn) => { | |
| btn.disabled = loading; | |
| }); | |
| } | |
| function fitDisplaySize() { | |
| const iw = frame.naturalWidth; | |
| const ih = frame.naturalHeight; | |
| if (!iw || !ih) return; | |
| const pad = 12; | |
| const maxW = Math.min(window.innerWidth - pad * 2, 960); | |
| const maxH = window.innerHeight - headerEl.offsetHeight - sceneBarEl.offsetHeight - pad * 2; | |
| const scale = Math.min(maxW / iw, maxH / ih, 2); | |
| const w = Math.max(1, Math.round(iw * scale)); | |
| const h = Math.max(1, Math.round(ih * scale)); | |
| frame.style.width = w + "px"; | |
| frame.style.height = h + "px"; | |
| viewWrap.style.width = w + "px"; | |
| viewWrap.style.height = h + "px"; | |
| syncOverlaySize(); | |
| } | |
| function syncOverlaySize() { | |
| const rect = frame.getBoundingClientRect(); | |
| const w = Math.max(1, Math.round(rect.width)); | |
| const h = Math.max(1, Math.round(rect.height)); | |
| overlay.width = w; | |
| overlay.height = h; | |
| overlay.style.width = w + "px"; | |
| overlay.style.height = h + "px"; | |
| } | |
| function clearOverlay() { | |
| ctx.clearRect(0, 0, overlay.width, overlay.height); | |
| } | |
| function drawArrow(x0, y0, x1, y1) { | |
| clearOverlay(); | |
| const dx = x1 - x0; | |
| const dy = y1 - y0; | |
| const len = Math.hypot(dx, dy); | |
| if (len < 2) { | |
| ctx.beginPath(); | |
| ctx.arc(x0, y0, 5, 0, Math.PI * 2); | |
| ctx.fillStyle = "rgba(80, 200, 255, 0.9)"; | |
| ctx.fill(); | |
| ctx.strokeStyle = "#fff"; | |
| ctx.lineWidth = 1.5; | |
| ctx.stroke(); | |
| return; | |
| } | |
| ctx.strokeStyle = "#4af"; | |
| ctx.fillStyle = "#4af"; | |
| ctx.lineWidth = 2.5; | |
| ctx.lineCap = "round"; | |
| ctx.beginPath(); | |
| ctx.moveTo(x0, y0); | |
| ctx.lineTo(x1, y1); | |
| ctx.stroke(); | |
| const head = Math.min(14, len * 0.35); | |
| const angle = Math.atan2(dy, dx); | |
| ctx.beginPath(); | |
| ctx.moveTo(x1, y1); | |
| ctx.lineTo( | |
| x1 - head * Math.cos(angle - Math.PI / 7), | |
| y1 - head * Math.sin(angle - Math.PI / 7) | |
| ); | |
| ctx.lineTo( | |
| x1 - head * Math.cos(angle + Math.PI / 7), | |
| y1 - head * Math.sin(angle + Math.PI / 7) | |
| ); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.beginPath(); | |
| ctx.arc(x0, y0, 5, 0, Math.PI * 2); | |
| ctx.fillStyle = "rgba(80, 200, 255, 0.95)"; | |
| ctx.fill(); | |
| ctx.strokeStyle = "#fff"; | |
| ctx.lineWidth = 1.5; | |
| ctx.stroke(); | |
| } | |
| function clientToOverlay(clientX, clientY) { | |
| const rect = overlay.getBoundingClientRect(); | |
| return { | |
| x: clientX - rect.left, | |
| y: clientY - rect.top, | |
| nx: (clientX - rect.left) / rect.width, | |
| ny: (clientY - rect.top) / rect.height, | |
| }; | |
| } | |
| async function postJson(url, body) { | |
| const res = await fetch(url, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(body), | |
| }); | |
| return res.json(); | |
| } | |
| overlay.addEventListener("pointerdown", (e) => { | |
| if (sceneLoading) return; | |
| e.preventDefault(); | |
| syncOverlaySize(); | |
| overlay.setPointerCapture(e.pointerId); | |
| dragging = true; | |
| const p = clientToOverlay(e.clientX, e.clientY); | |
| startX = curX = p.x; | |
| startY = curY = p.y; | |
| drawArrow(startX, startY, curX, curY); | |
| }); | |
| overlay.addEventListener("pointermove", (e) => { | |
| if (!dragging) return; | |
| e.preventDefault(); | |
| const p = clientToOverlay(e.clientX, e.clientY); | |
| curX = p.x; | |
| curY = p.y; | |
| drawArrow(startX, startY, curX, curY); | |
| }); | |
| async function finishDrag(e) { | |
| if (!dragging) return; | |
| dragging = false; | |
| if (overlay.hasPointerCapture(e.pointerId)) { | |
| overlay.releasePointerCapture(e.pointerId); | |
| } | |
| const end = clientToOverlay(e.clientX, e.clientY); | |
| const startNorm = { x: startX / overlay.width, y: startY / overlay.height }; | |
| const endNorm = { x: end.nx, y: end.ny }; | |
| clearOverlay(); | |
| const data = await postJson("/api/drag_release", { | |
| x0: startNorm.x, | |
| y0: startNorm.y, | |
| x1: endNorm.x, | |
| y1: endNorm.y, | |
| }); | |
| if (data.message) statusEl.textContent = data.message; | |
| } | |
| overlay.addEventListener("pointerup", finishDrag); | |
| overlay.addEventListener("pointercancel", finishDrag); | |
| pausedCb.addEventListener("change", () => { | |
| postJson("/api/pause", { paused: pausedCb.checked }); | |
| }); | |
| resetBtn.addEventListener("click", async () => { | |
| const data = await postJson("/api/reset", {}); | |
| if (data.message) statusEl.textContent = data.message; | |
| }); | |
| forceSlider.addEventListener("input", () => { | |
| forceVal.textContent = Number(forceSlider.value).toFixed(1); | |
| }); | |
| forceSlider.addEventListener("change", () => { | |
| postJson("/api/force", { magnitude: Number(forceSlider.value) }); | |
| }); | |
| function isStickyStatus(text) { | |
| return /^(Force |Missed |Drag too short|Simulation reset|Invalid drag|Loaded:|施力 |未命中|太短|无效拖动|加载中|加载场景)/.test(text); | |
| } | |
| function renderSceneList(scenes, activeId) { | |
| sceneList.innerHTML = ""; | |
| scenes.forEach((scene) => { | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = "scene-card" + (scene.id === activeId ? " active" : ""); | |
| btn.dataset.sceneId = scene.id; | |
| const img = document.createElement("img"); | |
| img.src = scene.preview_url + "&t=" + Date.now(); | |
| img.alt = scene.name; | |
| img.loading = "lazy"; | |
| const text = document.createElement("div"); | |
| text.className = "scene-text"; | |
| const name = document.createElement("div"); | |
| name.className = "scene-name"; | |
| name.textContent = scene.name; | |
| const desc = document.createElement("div"); | |
| desc.className = "scene-desc"; | |
| desc.textContent = scene.description || ""; | |
| text.appendChild(name); | |
| text.appendChild(desc); | |
| btn.appendChild(img); | |
| btn.appendChild(text); | |
| btn.addEventListener("click", () => selectScene(scene.id)); | |
| sceneList.appendChild(btn); | |
| }); | |
| } | |
| async function selectScene(sceneId) { | |
| if (sceneLoading || sceneId === currentSceneId) return; | |
| setSceneLoading(true); | |
| statusEl.textContent = "Loading scene / 加载场景中…"; | |
| try { | |
| const data = await postJson("/api/select_scene", { scene_id: sceneId }); | |
| if (data.ok) { | |
| if (data.message) statusEl.textContent = data.message; | |
| } else { | |
| statusEl.textContent = data.message || data.error || "Scene switch failed / 场景切换失败"; | |
| setSceneLoading(false); | |
| } | |
| } catch (err) { | |
| statusEl.textContent = "Scene load failed / 场景加载失败"; | |
| setSceneLoading(false); | |
| } | |
| } | |
| async function loadScenes() { | |
| const res = await fetch("/api/scenes"); | |
| const data = await res.json(); | |
| currentSceneId = data.current_scene_id; | |
| renderSceneList(data.scenes || [], currentSceneId); | |
| } | |
| async function refreshStatus() { | |
| try { | |
| const res = await fetch("/api/status"); | |
| const data = await res.json(); | |
| const nowLoading = !!data.loading; | |
| if (nowLoading) { | |
| setSceneLoading(true); | |
| } else if (wasServerLoading || sceneLoading) { | |
| setSceneLoading(false); | |
| if (frame.naturalWidth) fitDisplaySize(); | |
| } | |
| wasServerLoading = nowLoading; | |
| if (typeof data.current_scene_id === "string" && data.current_scene_id !== currentSceneId) { | |
| currentSceneId = data.current_scene_id; | |
| document.querySelectorAll(".scene-card").forEach((btn) => { | |
| btn.classList.toggle("active", btn.dataset.sceneId === currentSceneId); | |
| }); | |
| } | |
| if (!isStickyStatus(statusEl.textContent)) { | |
| statusEl.textContent = data.status || ""; | |
| } | |
| pausedCb.checked = !!data.paused; | |
| if (typeof data.force_magnitude === "number") { | |
| forceSlider.value = data.force_magnitude; | |
| forceVal.textContent = data.force_magnitude.toFixed(1); | |
| } | |
| if (typeof data.frame_version === "number" && data.frame_version !== lastFrameVersion) { | |
| lastFrameVersion = data.frame_version; | |
| refreshFrame(data.frame_version); | |
| } | |
| } catch (_) {} | |
| } | |
| function refreshFrame(frameVersion) { | |
| const v = frameVersion != null ? frameVersion : lastFrameVersion; | |
| frame.src = "/frame.jpg?v=" + v; | |
| } | |
| frame.addEventListener("load", fitDisplaySize); | |
| window.addEventListener("resize", fitDisplaySize); | |
| setInterval(refreshStatus, 200); | |
| loadScenes().then(() => { | |
| refreshStatus(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| def tensor_to_rgb(image_chw): | |
| """Convert (3, H, W) float tensor in [0, 1] to RGB uint8.""" | |
| img = image_chw.detach().clamp(0, 1).permute(1, 2, 0).cpu().numpy() | |
| return (img * 255).astype(np.uint8) | |
| def pixel_to_world_ray(camera, px, py): | |
| """Return (origin, direction) for a screen pixel. Both are CUDA float tensors.""" | |
| fx, fy = camera.Fx, camera.Fy | |
| cx, cy = camera.Cx, camera.Cy | |
| ray_cam = torch.tensor( | |
| [(px - cx) / fx, (py - cy) / fy, 1.0], | |
| dtype=torch.float32, | |
| device="cuda", | |
| ) | |
| ray_cam = ray_cam / torch.linalg.norm(ray_cam) | |
| c2w = torch.linalg.inv(camera.world_view_transform) | |
| rot = c2w[:3, :3] | |
| origin = camera.camera_center | |
| direction = rot @ ray_cam | |
| direction = direction / torch.linalg.norm(direction) | |
| return origin, direction | |
| def pick_world_point_on_ray(world_positions, origin, direction, max_dist=0.15): | |
| """Pick world-space point on ray closest to simulated particles.""" | |
| oc = world_positions - origin.unsqueeze(0) | |
| t = torch.sum(oc * direction.unsqueeze(0), dim=1) | |
| t = torch.clamp(t, min=0.0) | |
| closest = origin.unsqueeze(0) + t.unsqueeze(1) * direction.unsqueeze(0) | |
| dist = torch.linalg.norm(world_positions - closest, dim=1) | |
| valid = t > 0 | |
| if not torch.any(valid): | |
| return None, None, None | |
| dist = torch.where(valid, dist, torch.full_like(dist, float("inf"))) | |
| idx = int(torch.argmin(dist).item()) | |
| if float(dist[idx].item()) > max_dist: | |
| return None, None, None | |
| pick_t = float(t[idx].item()) | |
| return world_positions[idx].detach().cpu().numpy(), idx, pick_t | |
| def compute_drag_world_force(cam_width, cam_height, camera, x0, y0, x1, y1, pick_t, force_magnitude): | |
| """Map screen drag to a world-space force along the drag direction.""" | |
| px0 = int(np.clip(x0, 0.0, 1.0) * (cam_width - 1)) | |
| py0 = int(np.clip(y0, 0.0, 1.0) * (cam_height - 1)) | |
| px1 = int(np.clip(x1, 0.0, 1.0) * (cam_width - 1)) | |
| py1 = int(np.clip(y1, 0.0, 1.0) * (cam_height - 1)) | |
| drag_screen = np.hypot(x1 - x0, y1 - y0) | |
| if drag_screen < 0.01: | |
| return None, "Drag too short / 拖动太短" | |
| origin, dir0 = pixel_to_world_ray(camera, px0, py0) | |
| _, dir1 = pixel_to_world_ray(camera, px1, py1) | |
| p0 = origin + pick_t * dir0 | |
| p1 = origin + pick_t * dir1 | |
| drag_world = (p1 - p0).detach().cpu().numpy() | |
| drag_norm = float(np.linalg.norm(drag_world)) | |
| if drag_norm < 1e-8: | |
| return None, "Drag has no direction / 拖动方向无效" | |
| direction = drag_world / drag_norm | |
| magnitude = force_magnitude * drag_screen * 3.0 | |
| return (direction * magnitude).tolist(), None | |
| class _ViewerHTTPHandler(BaseHTTPRequestHandler): | |
| viewer = None | |
| def log_message(self, format, *args): | |
| return | |
| def _send_json(self, payload: dict) -> None: | |
| data = json.dumps(payload).encode("utf-8") | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self.send_header("Content-Length", str(len(data))) | |
| self.end_headers() | |
| self.wfile.write(data) | |
| def _send_bytes(self, data: bytes, content_type: str) -> None: | |
| self.send_response(200) | |
| self.send_header("Content-Type", content_type) | |
| self.send_header("Content-Length", str(len(data))) | |
| self.send_header("Cache-Control", "no-store") | |
| self.end_headers() | |
| self.wfile.write(data) | |
| def _read_json_body(self): | |
| length = int(self.headers.get("Content-Length", 0)) | |
| if length <= 0: | |
| return {} | |
| raw = self.rfile.read(length) | |
| return json.loads(raw.decode("utf-8")) | |
| def do_GET(self): | |
| path = urlparse(self.path).path | |
| viewer = self.viewer | |
| if path in ("/", "/index.html"): | |
| self._send_bytes(_INDEX_HTML.encode("utf-8"), "text/html; charset=utf-8") | |
| elif path == "/frame.jpg": | |
| with viewer._lock: | |
| data = viewer._jpeg_bytes | |
| if data is None: | |
| self.send_error(503) | |
| return | |
| self._send_bytes(data, "image/jpeg") | |
| elif path == "/api/status": | |
| self._send_json(viewer.status_dict()) | |
| elif path == "/api/scenes": | |
| self._send_json(viewer.scenes_dict()) | |
| elif path == "/api/scene_preview": | |
| query = parse_qs(urlparse(self.path).query) | |
| scene_id = (query.get("id") or [None])[0] | |
| preview = viewer.get_scene_preview_bytes(scene_id) | |
| if preview is None: | |
| self.send_error(404) | |
| return | |
| data, content_type = preview | |
| self._send_bytes(data, content_type) | |
| else: | |
| self.send_error(404) | |
| def do_POST(self): | |
| viewer = self.viewer | |
| path = urlparse(self.path).path | |
| try: | |
| body = self._read_json_body() | |
| except json.JSONDecodeError: | |
| self.send_error(400) | |
| return | |
| if path == "/api/drag_release": | |
| message = viewer.queue_drag_release( | |
| float(body.get("x0", 0.5)), | |
| float(body.get("y0", 0.5)), | |
| float(body.get("x1", 0.5)), | |
| float(body.get("y1", 0.5)), | |
| ) | |
| self._send_json({"ok": True, "message": message}) | |
| elif path == "/api/pause": | |
| viewer.paused = bool(body.get("paused", False)) | |
| self._send_json({"ok": True}) | |
| elif path == "/api/reset": | |
| viewer.queue_reset() | |
| self._send_json({"ok": True, "message": "Reset queued / 重置已排队"}) | |
| elif path == "/api/force": | |
| viewer.set_force_magnitude(float(body.get("magnitude", viewer.force_magnitude))) | |
| self._send_json({"ok": True}) | |
| elif path == "/api/select_scene": | |
| scene_id = body.get("scene_id") | |
| if not scene_id: | |
| self._send_json({"ok": False, "error": "Missing scene_id"}) | |
| return | |
| ok, message = viewer.queue_select_scene(str(scene_id)) | |
| self._send_json({"ok": ok, "message": message}) | |
| else: | |
| self.send_error(404) | |
| class InteractiveViewer: | |
| """Browser-based 2D image viewer (no 3D viewport).""" | |
| def __init__( | |
| self, | |
| session, | |
| session_factory, | |
| scenes, | |
| current_scene_id, | |
| force_magnitude=1.0, | |
| pick_radius=0.15, | |
| impulse_radius=0.05, | |
| host="0.0.0.0", | |
| port=8080, | |
| target_fps=15, | |
| downsample=0.5, | |
| white_bg=False, | |
| ): | |
| self.session = session | |
| self.session_factory = session_factory | |
| self.scenes = scenes | |
| self.scene_by_id = {s["id"]: s for s in scenes} | |
| self.current_scene_id = current_scene_id | |
| self.downsample = downsample | |
| self.white_bg = white_bg | |
| self.force_magnitude = self._clamp_force(force_magnitude) | |
| self.pick_radius = pick_radius | |
| self.impulse_radius = impulse_radius | |
| self.host = host | |
| self.port = port | |
| self.target_fps = target_fps | |
| self.paused = False | |
| self.running = True | |
| self.status = "Ready / 就绪" | |
| self._loading_scene = False | |
| self._lock = threading.Lock() | |
| self._action_lock = threading.Lock() | |
| self._pending_drag = None | |
| self._pending_scene_id = None | |
| self._burst_substeps = 0 | |
| self._reset_pending = False | |
| self._jpeg_bytes = None | |
| self._httpd = None | |
| self._preview_cache = {} | |
| self._frame_version = 0 | |
| def _clamp_force(self, value: float) -> float: | |
| return float(np.clip(value, _FORCE_MIN, _FORCE_MAX)) | |
| def set_force_magnitude(self, value: float) -> None: | |
| self.force_magnitude = self._clamp_force(value) | |
| def queue_reset(self) -> None: | |
| self._reset_pending = True | |
| def queue_select_scene(self, scene_id: str) -> tuple[bool, str]: | |
| if scene_id not in self.scene_by_id: | |
| return False, f"Unknown scene / 未知场景: {scene_id}" | |
| if scene_id == self.current_scene_id: | |
| return False, "Already active / 已是当前场景" | |
| with self._action_lock: | |
| self._pending_scene_id = scene_id | |
| return True, "Scene switch queued / 场景切换已排队" | |
| def get_scene_preview_bytes(self, scene_id: str): | |
| if not scene_id or scene_id not in self.scene_by_id: | |
| return None | |
| if scene_id in self._preview_cache: | |
| return self._preview_cache[scene_id] | |
| scene = self.scene_by_id[scene_id] | |
| preview_path = scene.get("preview") | |
| if not preview_path or not os.path.isfile(preview_path): | |
| return None | |
| mime, _ = mimetypes.guess_type(preview_path) | |
| if not mime: | |
| mime = "image/png" | |
| with open(preview_path, "rb") as f: | |
| data = f.read() | |
| self._preview_cache[scene_id] = (data, mime) | |
| return self._preview_cache[scene_id] | |
| def scenes_dict(self): | |
| return { | |
| "current_scene_id": self.current_scene_id, | |
| "scenes": [scene_public_view(s) for s in self.scenes], | |
| } | |
| def _reload_scene(self, scene_id: str) -> None: | |
| scene = self.scene_by_id[scene_id] | |
| self._loading_scene = True | |
| self.status = f"Loading {scene['name']} / 加载中…" | |
| print(f"Loading scene: {scene_id} ({scene['name']})") | |
| try: | |
| self.session = None | |
| torch.cuda.empty_cache() | |
| self.session = self.session_factory(scene) | |
| self.current_scene_id = scene_id | |
| self._burst_substeps = 0 | |
| self.status = f"Loaded: {scene['name']}" | |
| print(f"Scene ready: {scene_id}") | |
| except Exception as exc: | |
| self.status = f"Scene load failed / 场景加载失败: {exc}" | |
| print(f"Scene load failed: {scene_id}: {exc}") | |
| finally: | |
| self._loading_scene = False | |
| def queue_drag_release(self, x0: float, y0: float, x1: float, y1: float) -> str: | |
| with self._action_lock: | |
| self._pending_drag = (x0, y0, x1, y1) | |
| return "Drag queued / 拖动已排队" | |
| def _process_drag_release(self, x0: float, y0: float, x1: float, y1: float) -> None: | |
| camera = self.session.current_camera | |
| w = self.session.cam_width | |
| h = self.session.cam_height | |
| px0 = int(np.clip(x0, 0.0, 1.0) * (w - 1)) | |
| py0 = int(np.clip(y0, 0.0, 1.0) * (h - 1)) | |
| origin, direction = pixel_to_world_ray(camera, px0, py0) | |
| world_pos = self.session.get_world_positions() | |
| picked, _, pick_t = pick_world_point_on_ray( | |
| world_pos, origin, direction, max_dist=self.pick_radius | |
| ) | |
| if picked is None: | |
| self.status = f"Missed model / 未命中模型 ({px0}, {py0})" | |
| return | |
| force, err = compute_drag_world_force( | |
| w, h, camera, x0, y0, x1, y1, pick_t, self.force_magnitude | |
| ) | |
| if force is None: | |
| self.status = err or "Invalid drag / 无效拖动" | |
| return | |
| self.session.apply_impulse_at_world(picked, force, radius=self.impulse_radius) | |
| self._burst_substeps = self.session.substeps_per_frame | |
| fx, fy, fz = force | |
| self.status = ( | |
| f"Force ({fx:.2f}, {fy:.2f}, {fz:.2f}) / 施力 " | |
| f"at ({picked[0]:.2f}, {picked[1]:.2f}, {picked[2]:.2f})" | |
| ) | |
| def _process_pending(self) -> None: | |
| scene_id = None | |
| with self._action_lock: | |
| if self._pending_scene_id is not None: | |
| scene_id = self._pending_scene_id | |
| self._pending_scene_id = None | |
| self._pending_drag = None | |
| self._reset_pending = False | |
| if scene_id is not None: | |
| self._reload_scene(scene_id) | |
| return | |
| if self._reset_pending: | |
| self._reset_pending = False | |
| self.session.reset_simulation() | |
| self.status = "Simulation reset / 仿真已重置" | |
| return | |
| drag = None | |
| with self._action_lock: | |
| if self._pending_drag is not None: | |
| drag = self._pending_drag | |
| self._pending_drag = None | |
| if drag is not None: | |
| self._process_drag_release(*drag) | |
| def status_dict(self): | |
| scene = self.scene_by_id.get(self.current_scene_id, {}) | |
| return { | |
| "status": self.status, | |
| "paused": self.paused, | |
| "loading": self._loading_scene, | |
| "current_scene_id": self.current_scene_id, | |
| "current_scene_name": scene.get("name", ""), | |
| "force_magnitude": self.force_magnitude, | |
| "force_min": _FORCE_MIN, | |
| "force_max": _FORCE_MAX, | |
| "frame_version": self._frame_version, | |
| "time": float(self.session.mpm_solver.time) if self.session else 0.0, | |
| } | |
| def _encode_jpeg(self, rgb: np.ndarray) -> bytes: | |
| buf = io.BytesIO() | |
| Image.fromarray(rgb).save(buf, format="JPEG", quality=85) | |
| return buf.getvalue() | |
| def _start_http_server(self) -> None: | |
| _ViewerHTTPHandler.viewer = self | |
| self._httpd = ThreadingHTTPServer((self.host, self.port), _ViewerHTTPHandler) | |
| thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) | |
| thread.start() | |
| def run(self): | |
| self._start_http_server() | |
| display_host = "localhost" if self.host in ("0.0.0.0", "::") else self.host | |
| print(f"2D viewer running at http://{display_host}:{self.port}") | |
| print(" If remote: ssh -L 8080:localhost:8080 user@server") | |
| print(f" Then open http://localhost:{self.port} in your browser") | |
| print(" Drag & release to push / 拖动后松开施力") | |
| frame_interval = 1.0 / max(self.target_fps, 1.0) | |
| while self.running: | |
| t0 = time.time() | |
| self._process_pending() | |
| if self._loading_scene or self.session is None: | |
| time.sleep(0.05) | |
| continue | |
| n_substeps = 0 | |
| if self._burst_substeps > 0: | |
| n_substeps = self._burst_substeps | |
| self._burst_substeps = 0 | |
| elif not self.paused: | |
| n_substeps = self.session.substeps_per_frame | |
| if n_substeps > 0: | |
| self.session.step(n_substeps=n_substeps) | |
| rgb = tensor_to_rgb(self.session.render()) | |
| jpeg = self._encode_jpeg(rgb) | |
| with self._lock: | |
| self._jpeg_bytes = jpeg | |
| self._frame_version += 1 | |
| if not self.status.startswith( | |
| ("Force", "Missed", "Drag", "Simulation", "Invalid", "Loading", "Loaded", | |
| "施力", "未命中", "仿真", "无效", "太短", "加载") | |
| ): | |
| self.status = f"t={self.session.mpm_solver.time:.4f}s" | |
| elapsed = time.time() - t0 | |
| time.sleep(max(0.0, frame_interval - elapsed)) | |