Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Depth Anything V2 — In-Browser</title> | |
| <style> | |
| :root { | |
| --bg: #0f0f1a; | |
| --card: #1a1a2e; | |
| --accent: #6c63ff; | |
| --accent-hover: #5a52d5; | |
| --text: #e0e0e0; | |
| --text-dim: #888; | |
| --border: #2a2a4a; | |
| --radius: 12px; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| padding: 24px; | |
| } | |
| .container { max-width: 960px; margin: 0 auto; } | |
| h1 { font-size: 1.8rem; margin-bottom: 4px; } | |
| .subtitle { color: var(--text-dim); font-size: 0.95rem; margin-bottom: 24px; } | |
| .subtitle a { color: var(--accent); text-decoration: none; } | |
| .card { | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 24px; | |
| margin-bottom: 20px; | |
| } | |
| .controls { | |
| display: flex; | |
| gap: 16px; | |
| align-items: flex-end; | |
| flex-wrap: wrap; | |
| margin-bottom: 20px; | |
| } | |
| .field { display: flex; flex-direction: column; gap: 6px; } | |
| .field label { font-size: 0.85rem; color: var(--text-dim); } | |
| select, button { | |
| background: var(--bg); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 10px 16px; | |
| font-size: 0.95rem; | |
| cursor: pointer; | |
| transition: border-color 0.2s; | |
| } | |
| select:hover, button:hover { border-color: var(--accent); } | |
| button.primary { | |
| background: var(--accent); | |
| border-color: var(--accent); | |
| font-weight: 600; | |
| } | |
| button.primary:hover { background: var(--accent-hover); } | |
| button:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .upload-area { | |
| border: 2px dashed var(--border); | |
| border-radius: var(--radius); | |
| padding: 40px; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: border-color 0.2s, background 0.2s; | |
| margin-bottom: 16px; | |
| } | |
| .upload-area:hover, .upload-area.dragover { | |
| border-color: var(--accent); | |
| background: rgba(108, 99, 255, 0.05); | |
| } | |
| .upload-area p { color: var(--text-dim); font-size: 0.9rem; } | |
| .upload-area .icon { font-size: 2rem; margin-bottom: 8px; } | |
| #file-input { display: none; } | |
| .examples { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; } | |
| .examples span { font-size: 0.85rem; color: var(--text-dim); width: 100%; margin-bottom: 4px; } | |
| .example-img { | |
| width: 80px; height: 60px; | |
| object-fit: cover; | |
| border-radius: 6px; | |
| border: 2px solid var(--border); | |
| cursor: pointer; | |
| transition: border-color 0.2s; | |
| } | |
| .example-img:hover { border-color: var(--accent); } | |
| .progress-wrap { margin-bottom: 16px; } | |
| .progress-bar { | |
| height: 6px; | |
| background: var(--bg); | |
| border-radius: 3px; | |
| overflow: hidden; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: var(--accent); | |
| width: 0%; | |
| transition: width 0.3s; | |
| } | |
| .progress-label { font-size: 0.85rem; color: var(--text-dim); margin-top: 6px; } | |
| .result-container { position: relative; border-radius: var(--radius); overflow: hidden; } | |
| .slider-container { | |
| position: relative; | |
| display: none; | |
| user-select: none; | |
| } | |
| .slider-container.active { display: block; } | |
| .slider-container img { | |
| width: 100%; | |
| display: block; | |
| } | |
| .slider-overlay { | |
| position: absolute; | |
| top: 0; left: 0; | |
| width: 50%; | |
| height: 100%; | |
| overflow: hidden; | |
| } | |
| .slider-overlay img { | |
| width: 100vw; | |
| max-width: 912px; | |
| position: absolute; | |
| top: 0; left: 0; | |
| } | |
| .slider-handle { | |
| position: absolute; | |
| top: 0; bottom: 0; | |
| left: 50%; | |
| width: 4px; | |
| background: white; | |
| cursor: ew-resize; | |
| transform: translateX(-50%); | |
| } | |
| .slider-handle::after { | |
| content: '⇄'; | |
| position: absolute; | |
| top: 50%; left: 50%; | |
| transform: translate(-50%, -50%); | |
| background: white; | |
| color: #333; | |
| border-radius: 50%; | |
| width: 32px; height: 32px; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 1rem; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.3); | |
| } | |
| .status { | |
| padding: 12px 16px; | |
| border-radius: 8px; | |
| font-size: 0.9rem; | |
| margin-bottom: 16px; | |
| display: none; | |
| } | |
| .status.active { display: block; } | |
| .status.info { background: rgba(108, 99, 255, 0.15); border: 1px solid var(--accent); } | |
| .status.error { background: rgba(255, 80, 80, 0.15); border: 1px solid #ff5050; } | |
| .status.success { background: rgba(80, 255, 120, 0.1); border: 1px solid #50ff78; } | |
| .badge { | |
| display: inline-block; | |
| font-size: 0.75rem; | |
| padding: 2px 8px; | |
| border-radius: 4px; | |
| background: rgba(108, 99, 255, 0.2); | |
| color: var(--accent); | |
| margin-left: 8px; | |
| } | |
| .download-row { display: flex; gap: 10px; margin-top: 12px; } | |
| .download-row a { | |
| font-size: 0.85rem; | |
| color: var(--accent); | |
| text-decoration: none; | |
| padding: 6px 12px; | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| } | |
| .download-row a:hover { border-color: var(--accent); } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Depth Anything V2 <span class="badge">runs in your browser</span></h1> | |
| <p class="subtitle"> | |
| Monocular depth estimation via | |
| <a href="https://arxiv.org/abs/2406.09463" target="_blank">Depth Anything V2</a> | |
| (Yang et al., 2024) · powered by | |
| <a href="https://huggingface.co/docs/transformers.js" target="_blank">Transformers.js</a> | |
| + ONNX Runtime · no server, no GPU needed | |
| </p> | |
| <div class="card"> | |
| <div class="controls"> | |
| <div class="field"> | |
| <label for="model-select">Model size</label> | |
| <select id="model-select"> | |
| <option value="onnx-community/depth-anything-v2-small-ONNX" selected>Small (25M — fastest)</option> | |
| <option value="onnx-community/depth-anything-v2-base-ONNX">Base (98M — balanced)</option> | |
| <option value="onnx-community/depth-anything-v2-large-ONNX">Large (335M — best quality)</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label for="device-select">Device</label> | |
| <select id="device-select"> | |
| <option value="webgpu" selected>WebGPU (if available)</option> | |
| <option value="wasm">WASM (universal)</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label> </label> | |
| <button class="primary" id="compute-btn" disabled>Compute Depth</button> | |
| </div> | |
| </div> | |
| <div class="upload-area" id="upload-area"> | |
| <div class="icon">🖼️</div> | |
| <p>Click to upload or drag & drop an image</p> | |
| <p style="font-size:0.8rem; margin-top:4px;">JPG, PNG, WebP — max ~4K recommended</p> | |
| </div> | |
| <input type="file" id="file-input" accept="image/*" /> | |
| <div class="examples"> | |
| <span>Or try an example:</span> | |
| <img class="example-img" src="https://huggingface.co/datasets/Xenova/transformers.js-docs/resolve/main/cats.jpg" data-src="https://huggingface.co/datasets/Xenova/transformers.js-docs/resolve/main/cats.jpg" alt="cats" /> | |
| <img class="example-img" src="https://images.unsplash.com/photo-1518791841217-8f7f0d9c3864?w=160" data-src="https://images.unsplash.com/photo-1518791841217-8f7f0d9c3864?w=800" alt="cat portrait" /> | |
| <img class="example-img" src="https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=160" data-src="https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=800" alt="landscape" /> | |
| <img class="example-img" src="https://images.unsplash.com/photo-1543349689-9a4fe4c87b7a?w=160" data-src="https://images.unsplash.com/photo-1543349689-9a4fe4c87b7a?w=800" alt="city" /> | |
| <img class="example-img" src="https://images.unsplash.com/photo-1554629947-334ff61d85dc?w=160" data-src="https://images.unsplash.com/photo-1554629947-334ff61d85dc?w=800" alt="mountain" /> | |
| </div> | |
| <div class="progress-wrap" id="progress-wrap" style="display:none;"> | |
| <div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div> | |
| <div class="progress-label" id="progress-label">Initializing…</div> | |
| </div> | |
| <div class="status" id="status"></div> | |
| <div class="slider-container" id="slider-container"> | |
| <img id="depth-img" alt="Depth map" /> | |
| <div class="slider-overlay" id="slider-overlay"> | |
| <img id="original-img" alt="Original" /> | |
| </div> | |
| <div class="slider-handle" id="slider-handle"></div> | |
| </div> | |
| <div class="download-row" id="download-row" style="display:none;"> | |
| <a href="#" id="download-depth" download="depth.png">⬇ Download depth map (PNG)</a> | |
| </div> | |
| </div> | |
| <p class="subtitle" style="text-align:center;"> | |
| First run downloads the model (~25–335 MB depending on size) and caches it. | |
| Subsequent runs are instant to load. | |
| </p> | |
| </div> | |
| <script type="module"> | |
| import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.5'; | |
| // Allow remote models from the Hub | |
| env.allowLocalModels = false; | |
| const uploadArea = document.getElementById('upload-area'); | |
| const fileInput = document.getElementById('file-input'); | |
| const computeBtn = document.getElementById('compute-btn'); | |
| const modelSelect = document.getElementById('model-select'); | |
| const deviceSelect = document.getElementById('device-select'); | |
| const progressWrap = document.getElementById('progress-wrap'); | |
| const progressFill = document.getElementById('progress-fill'); | |
| const progressLabel = document.getElementById('progress-label'); | |
| const statusEl = document.getElementById('status'); | |
| const sliderContainer = document.getElementById('slider-container'); | |
| const depthImg = document.getElementById('depth-img'); | |
| const originalImg = document.getElementById('original-img'); | |
| const sliderOverlay = document.getElementById('slider-overlay'); | |
| const sliderHandle = document.getElementById('slider-handle'); | |
| const downloadRow = document.getElementById('download-row'); | |
| const downloadDepth = document.getElementById('download-depth'); | |
| let currentImageURL = null; | |
| let currentImageBitmap = null; | |
| let depthCanvas = null; | |
| let isComputing = false; | |
| // --- Status helpers --- | |
| function showStatus(msg, type = 'info') { | |
| statusEl.textContent = msg; | |
| statusEl.className = `status active ${type}`; | |
| } | |
| function hideStatus() { | |
| statusEl.className = 'status'; | |
| } | |
| function showProgress(pct, label) { | |
| progressWrap.style.display = 'block'; | |
| progressFill.style.width = `${pct}%`; | |
| if (label) progressLabel.textContent = label; | |
| } | |
| function hideProgress() { | |
| progressWrap.style.display = 'none'; | |
| progressFill.style.width = '0%'; | |
| } | |
| // --- Image loading --- | |
| async function loadImage(url) { | |
| const res = await fetch(url); | |
| const blob = await res.blob(); | |
| currentImageURL = URL.createObjectURL(blob); | |
| currentImageBitmap = await createImageBitmap(blob); | |
| computeBtn.disabled = false; | |
| hideStatus(); | |
| sliderContainer.classList.remove('active'); | |
| downloadRow.style.display = 'none'; | |
| } | |
| // --- Upload interactions --- | |
| uploadArea.addEventListener('click', () => fileInput.click()); | |
| uploadArea.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| uploadArea.classList.add('dragover'); | |
| }); | |
| uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover')); | |
| uploadArea.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| uploadArea.classList.remove('dragover'); | |
| const file = e.dataTransfer.files[0]; | |
| if (file && file.type.startsWith('image/')) handleFile(file); | |
| }); | |
| fileInput.addEventListener('change', (e) => { | |
| const file = e.target.files[0]; | |
| if (file) handleFile(file); | |
| }); | |
| function handleFile(file) { | |
| if (currentImageURL) URL.revokeObjectURL(currentImageURL); | |
| currentImageURL = URL.createObjectURL(file); | |
| currentImageBitmap = null; // will be loaded on compute | |
| computeBtn.disabled = false; | |
| hideStatus(); | |
| sliderContainer.classList.remove('active'); | |
| downloadRow.style.display = 'none'; | |
| } | |
| // --- Example images --- | |
| document.querySelectorAll('.example-img').forEach((img) => { | |
| img.addEventListener('click', async () => { | |
| showStatus('Loading example image…', 'info'); | |
| try { | |
| await loadImage(img.dataset.src); | |
| hideStatus(); | |
| } catch (err) { | |
| showStatus(`Failed to load example: ${err.message}`, 'error'); | |
| } | |
| }); | |
| }); | |
| // --- Depth colormap (Spectral_r) --- | |
| function spectralColormap(value) { | |
| // value: 0..1 → [r, g, b] | |
| const v = Math.max(0, Math.min(1, value)); | |
| // Approximate Spectral_r: red → orange → yellow → green → blue | |
| const stops = [ | |
| [0.0, [50, 0, 130]], | |
| [0.1, [100, 0, 200]], | |
| [0.2, [180, 30, 220]], | |
| [0.3, [220, 80, 180]], | |
| [0.4, [240, 120, 120]], | |
| [0.5, [250, 170, 60]], | |
| [0.6, [240, 200, 40]], | |
| [0.7, [200, 230, 30]], | |
| [0.8, [120, 240, 40]], | |
| [0.9, [40, 220, 80]], | |
| [1.0, [0, 180, 120]], | |
| ]; | |
| for (let i = 0; i < stops.length - 1; i++) { | |
| if (v <= stops[i + 1][0]) { | |
| const t = (v - stops[i][0]) / (stops[i + 1][0] - stops[i][0]); | |
| return [ | |
| Math.round(stops[i][1][0] + t * (stops[i + 1][1][0] - stops[i][1][0])), | |
| Math.round(stops[i][1][1] + t * (stops[i + 1][1][1] - stops[i][1][1])), | |
| Math.round(stops[i][1][2] + t * (stops[i + 1][1][2] - stops[i][1][2])), | |
| ]; | |
| } | |
| } | |
| return stops[stops.length - 1][1]; | |
| } | |
| // --- Compute depth --- | |
| computeBtn.addEventListener('click', async () => { | |
| if (isComputing || !currentImageURL) return; | |
| isComputing = true; | |
| computeBtn.disabled = true; | |
| computeBtn.textContent = 'Computing…'; | |
| hideStatus(); | |
| sliderContainer.classList.remove('active'); | |
| downloadRow.style.display = 'none'; | |
| const modelId = modelSelect.value; | |
| const device = deviceSelect.value; | |
| try { | |
| showProgress(0, 'Loading model (first run downloads weights)…'); | |
| let pipe; | |
| try { | |
| pipe = await pipeline('depth-estimation', modelId, { | |
| device: device, | |
| progress_callback: (data) => { | |
| if (data.status === 'progress') { | |
| const pct = data.progress ? Math.round(data.progress * 100) : 0; | |
| showProgress(pct, `Downloading ${data.file}… ${pct}%`); | |
| } else if (data.status === 'ready') { | |
| showProgress(100, 'Model loaded'); | |
| } | |
| }, | |
| }); | |
| } catch (gpuErr) { | |
| // Fallback to WASM if WebGPU fails | |
| if (device === 'webgpu') { | |
| showStatus('WebGPU unavailable — falling back to WASM…', 'info'); | |
| pipe = await pipeline('depth-estimation', modelId, { | |
| device: 'wasm', | |
| progress_callback: (data) => { | |
| if (data.status === 'progress') { | |
| const pct = data.progress ? Math.round(data.progress * 100) : 0; | |
| showProgress(pct, `Downloading ${data.file}… ${pct}%`); | |
| } | |
| }, | |
| }); | |
| } else { | |
| throw gpuErr; | |
| } | |
| } | |
| showProgress(100, 'Running inference…'); | |
| // Load image | |
| let imageInput = currentImageURL; | |
| const output = await pipe(imageInput); | |
| showProgress(100, 'Rendering depth map…'); | |
| // output.depth is a RawImage; output.prediction is also available | |
| const depth = output.depth || output.prediction; | |
| const { width, height, data } = depth; | |
| // Normalize depth values | |
| let min = Infinity, max = -Infinity; | |
| for (let i = 0; i < data.length; i++) { | |
| if (data[i] < min) min = data[i]; | |
| if (data[i] > max) max = data[i]; | |
| } | |
| const range = max - min || 1; | |
| // Build colored depth map | |
| depthCanvas = document.createElement('canvas'); | |
| depthCanvas.width = width; | |
| depthCanvas.height = height; | |
| const ctx = depthCanvas.getContext('2d'); | |
| const imageData = ctx.createImageData(width, height); | |
| for (let i = 0; i < data.length; i++) { | |
| const norm = (data[i] - min) / range; | |
| const [r, g, b] = spectralColormap(norm); | |
| imageData.data[i * 4] = r; | |
| imageData.data[i * 4 + 1] = g; | |
| imageData.data[i * 4 + 2] = b; | |
| imageData.data[i * 4 + 3] = 255; | |
| } | |
| ctx.putImageData(imageData, 0, 0); | |
| const depthDataURL = depthCanvas.toDataURL('image/png'); | |
| depthImg.src = depthDataURL; | |
| // Load original for slider overlay | |
| originalImg.src = currentImageURL; | |
| // Wait for images to load | |
| await Promise.all([ | |
| new Promise((res) => { depthImg.onload = res; }), | |
| new Promise((res) => { originalImg.onload = res; }), | |
| ]); | |
| // Set overlay image width to match container | |
| const containerWidth = sliderContainer.offsetWidth; | |
| originalImg.style.width = `${containerWidth}px`; | |
| sliderContainer.classList.add('active'); | |
| downloadDepth.href = depthDataURL; | |
| downloadRow.style.display = 'flex'; | |
| hideProgress(); | |
| showStatus('Depth estimation complete! Drag the slider to compare.', 'success'); | |
| setTimeout(hideStatus, 4000); | |
| } catch (err) { | |
| hideProgress(); | |
| showStatus(`Error: ${err.message}`, 'error'); | |
| console.error(err); | |
| } finally { | |
| isComputing = false; | |
| computeBtn.disabled = false; | |
| computeBtn.textContent = 'Compute Depth'; | |
| } | |
| }); | |
| // --- Slider interaction --- | |
| let isDragging = false; | |
| sliderHandle.addEventListener('mousedown', (e) => { isDragging = true; e.preventDefault(); }); | |
| document.addEventListener('mouseup', () => { isDragging = false; }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (!isDragging) return; | |
| updateSliderPosition(e.clientX); | |
| }); | |
| // Touch support | |
| sliderHandle.addEventListener('touchstart', (e) => { isDragging = true; e.preventDefault(); }, { passive: false }); | |
| document.addEventListener('touchend', () => { isDragging = false; }); | |
| document.addEventListener('touchmove', (e) => { | |
| if (!isDragging) return; | |
| updateSliderPosition(e.touches[0].clientX); | |
| }, { passive: false }); | |
| // Click on container to move slider | |
| sliderContainer.addEventListener('click', (e) => { | |
| if (e.target === sliderHandle) return; | |
| updateSliderPosition(e.clientX); | |
| }); | |
| function updateSliderPosition(clientX) { | |
| const rect = sliderContainer.getBoundingClientRect(); | |
| let pct = ((clientX - rect.left) / rect.width) * 100; | |
| pct = Math.max(0, Math.min(100, pct)); | |
| sliderOverlay.style.width = `${pct}%`; | |
| sliderHandle.style.left = `${pct}%`; | |
| } | |
| // Check WebGPU support and adjust default | |
| if (!navigator.gpu) { | |
| deviceSelect.value = 'wasm'; | |
| deviceSelect.querySelector('option[value="webgpu"]').textContent = 'WebGPU (unavailable)'; | |
| } | |
| </script> | |
| </body> | |
| </html> | |