Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>AR Drawing Overlay</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| font-family: sans-serif; | |
| background-color: #f9f9f9; | |
| text-align: center; | |
| overflow: hidden; /* Prevent scrollbars */ | |
| } | |
| #canvas { | |
| width: 100%; | |
| height: 85vh; | |
| max-width: 600px; | |
| border: 1px solid #ccc; | |
| touch-action: none; | |
| display: block; | |
| margin: 0 auto; | |
| } | |
| #controls { | |
| position: fixed; | |
| bottom: 0; | |
| left: 0; | |
| width: 100%; | |
| background: rgba(255, 255, 255, 0.9); | |
| padding: 5px; | |
| box-sizing: border-box; | |
| display: none; /* Hidden by default */ | |
| max-height: 25vh; | |
| overflow-y: auto; | |
| } | |
| button { | |
| font-size: 1em; | |
| padding: 5px; | |
| margin: 2px; | |
| min-width: 40px; | |
| min-height: 40px; | |
| border-radius: 5px; | |
| border: 1px solid #aaa; | |
| background-color: #f0f0f0; | |
| } | |
| button.active { | |
| background-color: #a0c4ff; /* Highlight active toggles */ | |
| border-color: #6a9eff; | |
| } | |
| #toggleControls { | |
| position: fixed; | |
| bottom: 10px; | |
| right: 10px; | |
| font-size: 1.5em; | |
| background: #fff; | |
| border: 1px solid #ccc; | |
| border-radius: 50%; | |
| width: 40px; | |
| height: 40px; | |
| z-index: 100; | |
| } | |
| #errorMessage { | |
| color: red; | |
| font-size: 0.8rem; | |
| margin: 5px 0; | |
| } | |
| #tutorialModal { | |
| display: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0,0,0,0.5); | |
| z-index: 101; | |
| } | |
| #tutorialContent { | |
| background: white; | |
| margin: 10% auto; | |
| padding: 10px; | |
| width: 90%; | |
| max-width: 350px; | |
| text-align: left; | |
| font-size: 0.8rem; | |
| border-radius: 8px; | |
| } | |
| .control-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| justify-content: center; | |
| gap: 5px; | |
| margin-bottom: 5px; | |
| } | |
| .control-row label, .control-row select, .control-row input { | |
| font-size: 0.8em; | |
| } | |
| #toastNotification { | |
| position: fixed; | |
| bottom: 70px; /* Above controls button */ | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: rgba(0, 0, 0, 0.7); | |
| color: white; | |
| padding: 10px 20px; | |
| border-radius: 20px; | |
| z-index: 102; | |
| opacity: 0; | |
| transition: opacity 0.5s ease-in-out; | |
| pointer-events: none; /* Don't block clicks */ | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h2>AR Drawing Overlay</h2> | |
| <p style="font-size: 0.9rem;">Trace pictures! Tap ≡ for controls.</p> | |
| <div id="errorMessage"></div> | |
| <video id="video" autoplay playsinline style="display: none;"></video> | |
| <canvas id="canvas"></canvas> | |
| <button id="toggleControls" aria-label="Toggle controls panel">≡</button> | |
| <div id="controls"> | |
| <div class="control-row"> | |
| <label for="cameraSelect">Pick Camera:</label> | |
| <select id="cameraSelect" onchange="switchCamera()"> | |
| <option value="">Select a camera</option> | |
| </select> | |
| </div> | |
| <div class="control-row"> | |
| <label for="overlayImage">Pick Image:</label> | |
| <input type="file" id="overlayImage" accept="image/png, image/jpeg, image/bmp, image/gif" /> | |
| </div> | |
| <div class="control-row"> | |
| <label for="transparency">Transparency:</label> | |
| <input type="range" id="transparency" min="0" max="1" step="0.01" value="0.5" style="width: 80px;" /> | |
| <span id="transparencyValue">0.5</span> | |
| </div> | |
| <div class="control-row"> | |
| <button onclick="moveLeft()" aria-label="Move overlay left">←</button> | |
| <button onclick="moveUp()" aria-label="Move overlay up">↑</button> | |
| <button onclick="moveDown()" aria-label="Move overlay down">↓</button> | |
| <button onclick="moveRight()" aria-label="Move overlay right">→</button> | |
| <button onclick="zoomIn()" aria-label="Zoom in overlay">+</button> | |
| <button onclick="zoomOut()" aria-label="Zoom out overlay">-</button> | |
| </div> | |
| <div class="control-row"> | |
| <button onclick="rotateLeft()" aria-label="Rotate left">↺</button> | |
| <button onclick="rotateRight()" aria-label="Rotate right">↻</button> | |
| <button onclick="flipHorizontal()" aria-label="Flip horizontally">Flip H</button> | |
| <button onclick="flipVertical()" aria-label="Flip vertically">Flip V</button> | |
| <button onclick="reset()" aria-label="Reset overlay">Reset</button> | |
| </div> | |
| <div class="control-row"> | |
| <button id="toggleCameraBtn" onclick="toggleCamera()" aria-label="Pause camera">Pause</button> | |
| <button id="edgeDetectBtn" onclick="toggleEdgeDetection()" aria-label="Toggle edge detection">Edges</button> | |
| <button id="lockBtn" onclick="toggleLock()" aria-label="Lock overlay position">🔒</button> | |
| <button onclick="takeSnapshot()" aria-label="Take snapshot">Snap</button> | |
| </div> | |
| <div class="control-row"> | |
| <button onclick="saveConfig()" aria-label="Save configuration">Save</button> | |
| <button onclick="loadConfig()" aria-label="Load configuration">Load</button> | |
| <button id="tutorialBtn" aria-label="Show tutorial">Help</button> | |
| </div> | |
| </div> | |
| <div id="toastNotification"></div> | |
| <!-- Tutorial Modal --> | |
| <div id="tutorialModal"> | |
| <div id="tutorialContent"> | |
| <h3>How to Use the AR Drawing Overlay</h3> | |
| <p>Hi! This helps you trace pictures:</p> | |
| <ol> | |
| <li><strong>Pick a Camera:</strong> Tap ≡, then choose a camera.</li> | |
| <li><strong>Pick a Picture:</strong> Tap "Pick Image" to choose a picture.</li> | |
| <li><strong>Adjust Overlay:</strong> | |
| <ul> | |
| <li>Use the slider for <strong>Transparency</strong>.</li> | |
| <li>Use ← ↑ ↓ → to <strong>move</strong> it. Drag with one finger.</li> | |
| <li>Use + and - to <strong>zoom</strong>. Pinch with two fingers.</li> | |
| <li>Use ↺ and ↻ to <strong>rotate</strong>. Twist with two fingers.</li> | |
| <li>Use <strong>Flip H/V</strong> to mirror the image.</li> | |
| </ul> | |
| </li> | |
| <li><strong>Lock It:</strong> Tap 🔒 to lock the overlay in place.</li> | |
| <li><strong>Trace Easier:</strong> Tap "Edges" to show only the outlines of your image.</li> | |
| <li><strong>Pause/Snap:</strong> Tap "Pause" to freeze the camera, and "Snap" to save a picture.</li> | |
| <li><strong>Save/Load:</strong> Save your setup for later with "Save" and "Load".</li> | |
| </ol> | |
| <p>Point your camera at paper and draw!</p> | |
| <button onclick="closeTutorial()">Close</button> | |
| </div> | |
| </div> | |
| <script> | |
| // --- Global State Variables --- | |
| let S_user = 1; | |
| let offsetXFraction = 0; | |
| let offsetYFraction = 0; | |
| let rotationAngle = 0; // In radians | |
| let isFlippedHorizontal = false; | |
| let isFlippedVertical = false; | |
| let isLocked = false; | |
| let edgeDetectionEnabled = false; | |
| let cameraPaused = false; | |
| let currentStream = null; | |
| let canvasRect; | |
| let selectedCameraId = null; | |
| // Image objects | |
| let originalOverlayImg = new Image(); | |
| let displayOverlayImg = new Image(); // This is what gets drawn | |
| let overlayLoaded = false; | |
| // --- DOM Elements --- | |
| const canvas = document.getElementById("canvas"); | |
| const video = document.getElementById("video"); | |
| const errorMessage = document.getElementById("errorMessage"); | |
| const cameraSelect = document.getElementById("cameraSelect"); | |
| const transparencySlider = document.getElementById("transparency"); | |
| const transparencyValueDisplay = document.getElementById("transparencyValue"); | |
| const lockBtn = document.getElementById("lockBtn"); | |
| const edgeDetectBtn = document.getElementById("edgeDetectBtn"); | |
| // --- Camera Functions --- | |
| async function populateCameraOptions() { | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) { | |
| errorMessage.textContent = "Camera selection not supported."; | |
| return; | |
| } | |
| try { | |
| const devices = await navigator.mediaDevices.enumerateDevices(); | |
| const videoDevices = devices.filter(device => device.kind === "videoinput"); | |
| cameraSelect.innerHTML = "<option value=''>Select a camera</option>"; | |
| videoDevices.forEach((device, index) => { | |
| const option = document.createElement("option"); | |
| option.value = device.deviceId; | |
| option.text = device.label || `Camera ${index + 1}`; | |
| cameraSelect.appendChild(option); | |
| }); | |
| if (videoDevices.length > 0) { | |
| cameraSelect.value = videoDevices[0].deviceId; | |
| selectedCameraId = videoDevices[0].deviceId; | |
| } | |
| } catch (error) { | |
| console.error("Error listing cameras:", error); | |
| errorMessage.textContent = "Error finding cameras: " + error.message; | |
| } | |
| } | |
| function startCamera() { | |
| if (currentStream) { | |
| currentStream.getTracks().forEach(track => track.stop()); | |
| currentStream = null; | |
| } | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |
| errorMessage.textContent = "Your browser doesn’t support the camera."; | |
| return; | |
| } | |
| const constraints = selectedCameraId | |
| ? { video: { deviceId: { exact: selectedCameraId } } } | |
| : { video: { facingMode: "environment" } }; | |
| navigator.mediaDevices | |
| .getUserMedia(constraints) | |
| .then((stream) => { | |
| currentStream = stream; | |
| video.srcObject = stream; | |
| video.onloadedmetadata = () => video.play(); | |
| errorMessage.textContent = ""; | |
| }) | |
| .catch((error) => { | |
| console.error("Camera error:", error); | |
| errorMessage.textContent = "Camera error: " + error.message; | |
| }); | |
| } | |
| function switchCamera() { | |
| selectedCameraId = cameraSelect.value; | |
| if (selectedCameraId && !cameraPaused) startCamera(); | |
| } | |
| function toggleCamera() { | |
| const toggleBtn = document.getElementById("toggleCameraBtn"); | |
| cameraPaused = !cameraPaused; | |
| if (cameraPaused) { | |
| if (currentStream) { | |
| currentStream.getTracks().forEach(track => track.stop()); | |
| currentStream = null; | |
| } | |
| toggleBtn.textContent = "Play"; | |
| } else { | |
| startCamera(); | |
| toggleBtn.textContent = "Pause"; | |
| } | |
| } | |
| // --- Overlay Transformation Controls --- | |
| function moveLeft() { if (isLocked) return; offsetXFraction -= 0.05; } | |
| function moveRight() { if (isLocked) return; offsetXFraction += 0.05; } | |
| function moveUp() { if (isLocked) return; offsetYFraction -= 0.05; } | |
| function moveDown() { if (isLocked) return; offsetYFraction += 0.05; } | |
| function zoomIn() { if (isLocked) return; S_user *= 1.1; } | |
| function zoomOut() { if (isLocked) return; S_user /= 1.1; } | |
| function rotateLeft() { if (isLocked) return; rotationAngle -= Math.PI / 18; } // 10 degrees | |
| function rotateRight() { if (isLocked) return; rotationAngle += Math.PI / 18; } | |
| function flipHorizontal() { if (isLocked) return; isFlippedHorizontal = !isFlippedHorizontal; } | |
| function flipVertical() { if (isLocked) return; isFlippedVertical = !isFlippedVertical; } | |
| function reset() { | |
| if (isLocked) return; | |
| S_user = 1; | |
| offsetXFraction = 0; | |
| offsetYFraction = 0; | |
| rotationAngle = 0; | |
| isFlippedHorizontal = false; | |
| isFlippedVertical = false; | |
| if (edgeDetectionEnabled) toggleEdgeDetection(); // Turn off edge detection | |
| showToast("Overlay Reset"); | |
| } | |
| function toggleLock() { | |
| isLocked = !isLocked; | |
| lockBtn.textContent = isLocked ? "🔓" : "🔒"; | |
| lockBtn.classList.toggle('active', isLocked); | |
| showToast(isLocked ? "Overlay Locked" : "Overlay Unlocked"); | |
| } | |
| // --- Image Handling & Processing --- | |
| document.getElementById("overlayImage").addEventListener("change", (e) => { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| originalOverlayImg.src = event.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| }); | |
| originalOverlayImg.onload = () => { | |
| overlayLoaded = true; | |
| if (edgeDetectionEnabled) { | |
| applyEdgeDetection(); | |
| } else { | |
| displayOverlayImg.src = originalOverlayImg.src; | |
| } | |
| errorMessage.textContent = ""; | |
| showToast("Image loaded"); | |
| }; | |
| function toggleEdgeDetection() { | |
| if (!overlayLoaded) { | |
| showToast("Please load an image first."); | |
| return; | |
| } | |
| edgeDetectionEnabled = !edgeDetectionEnabled; | |
| edgeDetectBtn.classList.toggle('active', edgeDetectionEnabled); | |
| if (edgeDetectionEnabled) { | |
| showToast("Applying edge detection..."); | |
| setTimeout(applyEdgeDetection, 10); // Use timeout to show toast first | |
| } else { | |
| displayOverlayImg.src = originalOverlayImg.src; | |
| showToast("Edge detection off."); | |
| } | |
| } | |
| function applyEdgeDetection() { | |
| const offscreenCanvas = document.createElement('canvas'); | |
| const offscreenCtx = offscreenCanvas.getContext('2d', { willReadFrequently: true }); | |
| offscreenCanvas.width = originalOverlayImg.naturalWidth; | |
| offscreenCanvas.height = originalOverlayImg.naturalHeight; | |
| // 1. Grayscale | |
| offscreenCtx.drawImage(originalOverlayImg, 0, 0); | |
| const imageData = offscreenCtx.getImageData(0, 0, offscreenCanvas.width, offscreenCanvas.height); | |
| const data = imageData.data; | |
| for (let i = 0; i < data.length; i += 4) { | |
| const avg = (data[i] + data[i + 1] + data[i + 2]) / 3; | |
| data[i] = data[i + 1] = data[i + 2] = avg; | |
| } | |
| // 2. Sobel Operator for edge detection | |
| const sobelData = sobel(imageData); | |
| const sobelImageData = offscreenCtx.createImageData(offscreenCanvas.width, offscreenCanvas.height); | |
| for (let i = 0; i < sobelData.data.length; i += 4) { | |
| const magnitude = sobelData.data[i]; | |
| sobelImageData.data[i] = magnitude; | |
| sobelImageData.data[i + 1] = magnitude; | |
| sobelImageData.data[i + 2] = magnitude; | |
| sobelImageData.data[i + 3] = 255; | |
| } | |
| offscreenCtx.putImageData(sobelImageData, 0, 0); | |
| displayOverlayImg.src = offscreenCanvas.toDataURL(); | |
| showToast("Edge detection applied."); | |
| } | |
| // Sobel operator function (simplified) | |
| function sobel(imageData) { | |
| const width = imageData.width; | |
| const height = imageData.height; | |
| const grayscaleData = new Uint8ClampedArray(width * height); | |
| for (let i = 0; i < imageData.data.length; i += 4) { | |
| grayscaleData[i / 4] = imageData.data[i]; | |
| } | |
| const kernelX = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]; | |
| const kernelY = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]]; | |
| const sobelData = new Uint8ClampedArray(width * height * 4); | |
| for (let y = 1; y < height - 1; y++) { | |
| for (let x = 1; x < width - 1; x++) { | |
| let pixelX = 0; | |
| let pixelY = 0; | |
| for (let j = -1; j <= 1; j++) { | |
| for (let i = -1; i <= 1; i++) { | |
| const pixel = grayscaleData[(y + j) * width + (x + i)]; | |
| pixelX += pixel * kernelX[j + 1][i + 1]; | |
| pixelY += pixel * kernelY[j + 1][i + 1]; | |
| } | |
| } | |
| const magnitude = Math.sqrt(pixelX * pixelX + pixelY * pixelY) > 128 ? 0 : 255; | |
| const index = (y * width + x) * 4; | |
| sobelData[index] = sobelData[index + 1] = sobelData[index + 2] = magnitude; | |
| sobelData[index + 3] = 255; | |
| } | |
| } | |
| return { data: sobelData, width: width, height: height }; | |
| } | |
| // --- Touch Controls --- | |
| let isDragging = false, isPinching = false, isRotating = false; | |
| let touchStartX = 0, touchStartY = 0; | |
| let startOffsetXFraction = 0, startOffsetYFraction = 0; | |
| let initialPinchDistance = 0, initialScale = 1; | |
| let initialTouchAngle = 0, startRotationAngle = 0; | |
| function getDistance(t1, t2) { return Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); } | |
| function getAngle(t1, t2) { return Math.atan2(t2.clientY - t1.clientY, t2.clientX - t1.clientX); } | |
| canvas.addEventListener("touchstart", (e) => { | |
| if (isLocked || !overlayLoaded) return; | |
| canvasRect = canvas.getBoundingClientRect(); | |
| if (e.touches.length === 1) { | |
| isDragging = true; | |
| touchStartX = e.touches[0].clientX; | |
| touchStartY = e.touches[0].clientY; | |
| startOffsetXFraction = offsetXFraction; | |
| startOffsetYFraction = offsetYFraction; | |
| } else if (e.touches.length === 2) { | |
| isPinching = true; | |
| isRotating = true; | |
| initialPinchDistance = getDistance(e.touches[0], e.touches[1]); | |
| initialScale = S_user; | |
| initialTouchAngle = getAngle(e.touches[0], e.touches[1]); | |
| startRotationAngle = rotationAngle; | |
| } | |
| }, false); | |
| canvas.addEventListener("touchmove", (e) => { | |
| e.preventDefault(); | |
| if (isLocked || !overlayLoaded) return; | |
| if (isDragging && e.touches.length === 1) { | |
| offsetXFraction = startOffsetXFraction + (e.touches[0].clientX - touchStartX) / canvasRect.width; | |
| offsetYFraction = startOffsetYFraction + (e.touches[0].clientY - touchStartY) / canvasRect.height; | |
| } else if ((isPinching || isRotating) && e.touches.length === 2) { | |
| // Pinch to Zoom | |
| const currentDistance = getDistance(e.touches[0], e.touches[1]); | |
| S_user = initialScale * (currentDistance / initialPinchDistance); | |
| // Two-finger rotate | |
| const currentAngle = getAngle(e.touches[0], e.touches[1]); | |
| rotationAngle = startRotationAngle + (currentAngle - initialTouchAngle); | |
| } | |
| }, { passive: false }); | |
| canvas.addEventListener("touchend", (e) => { | |
| if (e.touches.length < 2) { isPinching = false; isRotating = false; } | |
| if (e.touches.length < 1) { isDragging = false; } | |
| }, false); | |
| // --- Main Draw Loop --- | |
| function draw() { | |
| const ctx = canvas.getContext("2d"); | |
| if (!cameraPaused && video.readyState === video.HAVE_ENOUGH_DATA) { | |
| if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) { | |
| canvas.width = video.videoWidth; | |
| canvas.height = video.videoHeight; | |
| } | |
| ctx.drawImage(video, 0, 0, canvas.width, canvas.height); | |
| if (overlayLoaded && displayOverlayImg.complete) { | |
| const S_fit = Math.min(canvas.width / displayOverlayImg.naturalWidth, canvas.height / displayOverlayImg.naturalHeight); | |
| const S = S_fit * S_user; | |
| const imgWidth = S * displayOverlayImg.naturalWidth; | |
| const imgHeight = S * displayOverlayImg.naturalHeight; | |
| const dx = (canvas.width - imgWidth) / 2 + offsetXFraction * canvas.width; | |
| const dy = (canvas.height - imgHeight) / 2 + offsetYFraction * canvas.height; | |
| ctx.globalAlpha = 1.0 - parseFloat(transparencySlider.value); | |
| // Apply transformations (rotation, flip) | |
| ctx.save(); | |
| ctx.translate(dx + imgWidth / 2, dy + imgHeight / 2); // Move origin to image center | |
| ctx.rotate(rotationAngle); | |
| ctx.scale(isFlippedHorizontal ? -1 : 1, isFlippedVertical ? -1 : 1); | |
| ctx.drawImage(displayOverlayImg, -imgWidth / 2, -imgHeight / 2, imgWidth, imgHeight); // Draw centered on new origin | |
| ctx.restore(); // Restore context to original state | |
| ctx.globalAlpha = 1.0; | |
| } | |
| } | |
| requestAnimationFrame(draw); | |
| } | |
| // --- Utility & UI Functions --- | |
| function takeSnapshot() { | |
| const link = document.createElement("a"); | |
| link.href = canvas.toDataURL("image/png"); | |
| link.download = "ar_drawing_snapshot.png"; | |
| link.click(); | |
| } | |
| function showToast(message) { | |
| const toast = document.getElementById("toastNotification"); | |
| toast.textContent = message; | |
| toast.style.opacity = 1; | |
| setTimeout(() => { | |
| toast.style.opacity = 0; | |
| }, 2500); | |
| } | |
| function saveConfig() { | |
| const transparency = transparencySlider.value; | |
| const config = { S_user, offsetXFraction, offsetYFraction, transparency, rotationAngle, isFlippedHorizontal, isFlippedVertical }; | |
| localStorage.setItem("arOverlayConfig", JSON.stringify(config)); | |
| showToast("Configuration Saved!"); | |
| } | |
| function loadConfig() { | |
| const config = localStorage.getItem("arOverlayConfig"); | |
| if (config) { | |
| const parsed = JSON.parse(config); | |
| S_user = parsed.S_user || 1; | |
| offsetXFraction = parsed.offsetXFraction || 0; | |
| offsetYFraction = parsed.offsetYFraction || 0; | |
| rotationAngle = parsed.rotationAngle || 0; | |
| isFlippedHorizontal = parsed.isFlippedHorizontal || false; | |
| isFlippedVertical = parsed.isFlippedVertical || false; | |
| transparencySlider.value = parsed.transparency || 0.5; | |
| transparencyValueDisplay.textContent = parsed.transparency || 0.5; | |
| showToast("Configuration Loaded!"); | |
| } else { | |
| showToast("No saved configuration found."); | |
| } | |
| } | |
| document.getElementById("toggleControls").addEventListener("click", function () { | |
| const controls = document.getElementById("controls"); | |
| controls.style.display = window.getComputedStyle(controls).display === "none" ? "block" : "none"; | |
| }); | |
| document.getElementById("tutorialBtn").addEventListener("click", () => { | |
| document.getElementById("tutorialModal").style.display = "block"; | |
| }); | |
| function closeTutorial() { | |
| document.getElementById("tutorialModal").style.display = "none"; | |
| } | |
| transparencySlider.addEventListener("input", () => { | |
| transparencyValueDisplay.textContent = transparencySlider.value; | |
| }); | |
| // --- Initialization --- | |
| window.addEventListener("load", () => { | |
| populateCameraOptions().then(() => startCamera()); | |
| draw(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |