Spaces:
Running
Running
make a tool where i can upload all images of woman body anatomy photos of different angles lenses and perspectives and crops and have a auto breast aligner that scales and aligns the breast scale for 3d reference
3809460 verified | <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>BoobAlign Pro - 3D Breast Reference Tool</title> | |
| <link rel="icon" type="image/x-icon" href="/static/favicon.ico"> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script> | |
| <style> | |
| .dropzone { | |
| border: 3px dashed #9CA3AF; | |
| transition: all 0.3s; | |
| } | |
| .dropzone-active { | |
| border-color: #6366F1; | |
| background-color: #EEF2FF; | |
| } | |
| #canvas-container { | |
| position: relative; | |
| } | |
| .landmark { | |
| position: absolute; | |
| width: 12px; | |
| height: 12px; | |
| background: #EC4899; | |
| border-radius: 50%; | |
| transform: translate(-50%, -50%); | |
| cursor: move; | |
| z-index: 10; | |
| } | |
| .tools-panel { | |
| transition: all 0.3s ease; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen"> | |
| <div class="container mx-auto px-4 py-8"> | |
| <header class="mb-10 text-center"> | |
| <h1 class="text-4xl font-bold text-gray-800 mb-2">BoobAlign Pro</h1> | |
| <p class="text-lg text-gray-600">Precision breast alignment for 3D modeling reference</p> | |
| </header> | |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> | |
| <!-- Upload Panel --> | |
| <div class="lg:col-span-1 bg-white rounded-xl shadow-lg overflow-hidden"> | |
| <div class="p-6 border-b border-gray-200"> | |
| <h2 class="text-xl font-semibold text-gray-800">Upload Images</h2> | |
| </div> | |
| <div class="p-6"> | |
| <div id="dropzone" class="dropzone rounded-lg p-8 text-center cursor-pointer mb-6"> | |
| <i data-feather="upload-cloud" class="w-12 h-12 mx-auto text-gray-400 mb-3"></i> | |
| <p class="text-gray-600 mb-1">Drag & drop images here</p> | |
| <p class="text-sm text-gray-500">or click to browse</p> | |
| <input type="file" id="file-input" class="hidden" multiple accept="image/*"> | |
| </div> | |
| <button id="process-btn" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-3 px-4 rounded-lg transition disabled:opacity-50" disabled> | |
| <span class="flex items-center justify-center"> | |
| <i data-feather="cpu" class="w-5 h-5 mr-2"></i> | |
| Process Images | |
| </span> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Main Canvas --> | |
| <div class="lg:col-span-2"> | |
| <div id="canvas-container" class="bg-white rounded-xl shadow-lg overflow-hidden"> | |
| <div class="p-4 border-b border-gray-200 flex justify-between items-center"> | |
| <h2 class="text-xl font-semibold text-gray-800">Alignment Canvas</h2> | |
| <div class="flex space-x-2"> | |
| <button id="zoom-in" class="p-2 rounded hover:bg-gray-100"> | |
| <i data-feather="zoom-in"></i> | |
| </button> | |
| <button id="zoom-out" class="p-2 rounded hover:bg-gray-100"> | |
| <i data-feather="zoom-out"></i> | |
| </button> | |
| <button id="reset-view" class="p-2 rounded hover:bg-gray-100"> | |
| <i data-feather="refresh-cw"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="relative" style="height: 500px;"> | |
| <canvas id="main-canvas" class="absolute inset-0 w-full h-full"></canvas> | |
| <div id="landmark-container" class="absolute inset-0 pointer-events-none"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Tools Panel (hidden by default) --> | |
| <div id="tools-panel" class="mt-8 bg-white rounded-xl shadow-lg overflow-hidden hidden"> | |
| <div class="p-6 border-b border-gray-200"> | |
| <h2 class="text-xl font-semibold text-gray-800">Alignment Tools</h2> | |
| </div> | |
| <div class="p-6 grid grid-cols-1 md:grid-cols-3 gap-6"> | |
| <div> | |
| <h3 class="font-medium text-gray-700 mb-3">Landmarks</h3> | |
| <div class="space-y-2"> | |
| <button id="add-nipple" class="w-full flex items-center justify-between px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg"> | |
| <span>Add Nipple Point</span> | |
| <i data-feather="circle" class="w-4 h-4 text-pink-500"></i> | |
| </button> | |
| <button id="add-underbust" class="w-full flex items-center justify-between px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg"> | |
| <span>Add Underbust Point</span> | |
| <i data-feather="circle" class="w-4 h-4 text-purple-500"></i> | |
| </button> | |
| <button id="add-side" class="w-full flex items-center justify-between px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg"> | |
| <span>Add Side Point</span> | |
| <i data-feather="circle" class="w-4 h-4 text-blue-500"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <div> | |
| <h3 class="font-medium text-gray-700 mb-3">Alignment</h3> | |
| <div class="space-y-2"> | |
| <button id="auto-align" class="w-full flex items-center justify-between px-4 py-2 bg-indigo-100 hover:bg-indigo-200 rounded-lg"> | |
| <span>Auto-Align Breasts</span> | |
| <i data-feather="move" class="w-4 h-4 text-indigo-600"></i> | |
| </button> | |
| <button id="symmetry-check" class="w-full flex items-center justify-between px-4 py-2 bg-indigo-100 hover:bg-indigo-200 rounded-lg"> | |
| <span>Check Symmetry</span> | |
| <i data-feather="mirror" class="w-4 h-4 text-indigo-600"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <div> | |
| <h3 class="font-medium text-gray-700 mb-3">Export</h3> | |
| <div class="space-y-2"> | |
| <button id="export-json" class="w-full flex items-center justify-between px-4 py-2 bg-green-100 hover:bg-green-200 rounded-lg"> | |
| <span>Export as JSON</span> | |
| <i data-feather="download" class="w-4 h-4 text-green-600"></i> | |
| </button> | |
| <button id="export-image" class="w-full flex items-center justify-between px-4 py-2 bg-green-100 hover:bg-green-200 rounded-lg"> | |
| <span>Export as Image</span> | |
| <i data-feather="image" class="w-4 h-4 text-green-600"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Gallery (hidden by default) --> | |
| <div id="gallery" class="mt-8 hidden"> | |
| <div class="bg-white rounded-xl shadow-lg overflow-hidden"> | |
| <div class="p-6 border-b border-gray-200"> | |
| <h2 class="text-xl font-semibold text-gray-800">Image Gallery</h2> | |
| </div> | |
| <div class="p-6"> | |
| <div id="thumbnails" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> | |
| <!-- Thumbnails will be added here --> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', function() { | |
| feather.replace(); | |
| // Initialize Fabric.js canvas | |
| const canvas = new fabric.Canvas('main-canvas', { | |
| backgroundColor: '#f8fafc', | |
| preserveObjectStacking: true | |
| }); | |
| let currentImage = null; | |
| let uploadedImages = []; | |
| let landmarks = []; | |
| // Dropzone functionality | |
| const dropzone = document.getElementById('dropzone'); | |
| const fileInput = document.getElementById('file-input'); | |
| dropzone.addEventListener('click', () => fileInput.click()); | |
| dropzone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| dropzone.classList.add('dropzone-active'); | |
| }); | |
| dropzone.addEventListener('dragleave', () => { | |
| dropzone.classList.remove('dropzone-active'); | |
| }); | |
| dropzone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dropzone.classList.remove('dropzone-active'); | |
| handleFiles(e.dataTransfer.files); | |
| }); | |
| fileInput.addEventListener('change', () => { | |
| if (fileInput.files.length > 0) { | |
| handleFiles(fileInput.files); | |
| } | |
| }); | |
| function handleFiles(files) { | |
| const processBtn = document.getElementById('process-btn'); | |
| for (let i = 0; i < files.length; i++) { | |
| const file = files[i]; | |
| if (!file.type.match('image.*')) continue; | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| uploadedImages.push({ | |
| name: file.name, | |
| src: e.target.result | |
| }); | |
| // Update UI | |
| processBtn.disabled = false; | |
| document.getElementById('gallery').classList.remove('hidden'); | |
| // Add thumbnail to gallery | |
| const thumbnails = document.getElementById('thumbnails'); | |
| const thumbnail = document.createElement('div'); | |
| thumbnail.className = 'cursor-pointer group'; | |
| thumbnail.innerHTML = ` | |
| <div class="relative overflow-hidden rounded-lg aspect-square"> | |
| <img src="${e.target.result}" class="w-full h-full object-cover group-hover:opacity-75 transition"> | |
| <div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition"></div> | |
| </div> | |
| <p class="text-sm text-gray-600 truncate mt-1">${file.name}</p> | |
| `; | |
| thumbnail.addEventListener('click', () => loadImageToCanvas(e.target.result, file.name)); | |
| thumbnails.appendChild(thumbnail); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| } | |
| function loadImageToCanvas(src, name) { | |
| const img = new Image(); | |
| img.src = src; | |
| img.onload = function() { | |
| // Clear previous image and landmarks | |
| canvas.clear(); | |
| document.getElementById('landmark-container').innerHTML = ''; | |
| landmarks = []; | |
| // Add new image | |
| const fabricImg = new fabric.Image(img, { | |
| left: canvas.width / 2, | |
| top: canvas.height / 2, | |
| originX: 'center', | |
| originY: 'center', | |
| selectable: false | |
| }); | |
| // Scale image to fit canvas | |
| const scale = Math.min( | |
| (canvas.width * 0.9) / img.width, | |
| (canvas.height * 0.9) / img.height | |
| ); | |
| fabricImg.scale(scale); | |
| canvas.add(fabricImg); | |
| currentImage = fabricImg; | |
| // Show tools panel | |
| document.getElementById('tools-panel').classList.remove('hidden'); | |
| }; | |
| } | |
| // Add landmark functionality | |
| document.getElementById('add-nipple').addEventListener('click', () => addLandmark('nipple', 'bg-pink-500')); | |
| document.getElementById('add-underbust').addEventListener('click', () => addLandmark('underbust', 'bg-purple-500')); | |
| document.getElementById('add-side').addEventListener('click', () => addLandmark('side', 'bg-blue-500')); | |
| function addLandmark(type, colorClass) { | |
| if (!currentImage) return; | |
| // Center position (temporary, user will drag it) | |
| const container = document.getElementById('landmark-container'); | |
| const landmark = document.createElement('div'); | |
| landmark.className = `landmark ${colorClass} cursor-move`; | |
| landmark.dataset.type = type; | |
| landmark.style.left = '50%'; | |
| landmark.style.top = '50%'; | |
| // Make draggable | |
| let isDragging = false; | |
| let offsetX, offsetY; | |
| landmark.addEventListener('mousedown', function(e) { | |
| isDragging = true; | |
| offsetX = e.clientX - landmark.getBoundingClientRect().left; | |
| offsetY = e.clientY - landmark.getBoundingClientRect().top; | |
| e.preventDefault(); // Prevent text selection | |
| }); | |
| document.addEventListener('mousemove', function(e) { | |
| if (!isDragging) return; | |
| const containerRect = container.getBoundingClientRect(); | |
| let x = e.clientX - containerRect.left - offsetX; | |
| let y = e.clientY - containerRect.top - offsetY; | |
| // Constrain to container | |
| x = Math.max(0, Math.min(x, containerRect.width)); | |
| y = Math.max(0, Math.min(y, containerRect.height)); | |
| landmark.style.left = x + 'px'; | |
| landmark.style.top = y + 'px'; | |
| }); | |
| document.addEventListener('mouseup', function() { | |
| isDragging = false; | |
| updateLandmarkPositions(); | |
| }); | |
| container.appendChild(landmark); | |
| updateLandmarkPositions(); | |
| } | |
| function updateLandmarkPositions() { | |
| const container = document.getElementById('landmark-container'); | |
| const containerRect = container.getBoundingClientRect(); | |
| const canvasRect = canvas.getSelectionElement().getBoundingClientRect(); | |
| const scaleX = canvas.width / canvasRect.width; | |
| const scaleY = canvas.height / canvasRect.height; | |
| landmarks = []; | |
| Array.from(container.children).forEach(landmark => { | |
| const rect = landmark.getBoundingClientRect(); | |
| const x = (rect.left + rect.width/2 - canvasRect.left) * scaleX; | |
| const y = (rect.top + rect.height/2 - canvasRect.top) * scaleY; | |
| landmarks.push({ | |
| type: landmark.dataset.type, | |
| x: x, | |
| y: y | |
| }); | |
| }); | |
| } | |
| // Process button functionality | |
| document.getElementById('process-btn').addEventListener('click', function() { | |
| if (uploadedImages.length === 0) return; | |
| // For demo purposes, just load the first image | |
| loadImageToCanvas(uploadedImages[0].src, uploadedImages[0].name); | |
| }); | |
| // Zoom controls | |
| document.getElementById('zoom-in').addEventListener('click', function() { | |
| if (currentImage) { | |
| currentImage.scaleX *= 1.1; | |
| currentImage.scaleY *= 1.1; | |
| canvas.renderAll(); | |
| } | |
| }); | |
| document.getElementById('zoom-out').addEventListener('click', function() { | |
| if (currentImage) { | |
| currentImage.scaleX *= 0.9; | |
| currentImage.scaleY *= 0.9; | |
| canvas.renderAll(); | |
| } | |
| }); | |
| document.getElementById('reset-view').addEventListener('click', function() { | |
| if (currentImage) { | |
| const img = currentImage.getElement(); | |
| const scale = Math.min( | |
| (canvas.width * 0.9) / img.width, | |
| (canvas.height * 0.9) / img.height | |
| ); | |
| currentImage.scaleX = scale; | |
| currentImage.scaleY = scale; | |
| currentImage.left = canvas.width / 2; | |
| currentImage.top = canvas.height / 2; | |
| currentImage.setCoords(); | |
| canvas.renderAll(); | |
| } | |
| }); | |
| // Auto-align functionality (simplified for demo) | |
| document.getElementById('auto-align').addEventListener('click', function() { | |
| if (landmarks.length < 2) return; | |
| // Simple demo: align nipples horizontally | |
| const nipples = landmarks.filter(l => l.type === 'nipple'); | |
| if (nipples.length >= 2) { | |
| const avgY = nipples.reduce((sum, l) => sum + l.y, 0) / nipples.length; | |
| nipples.forEach(nipple => { | |
| const element = Array.from(document.getElementById('landmark-container').children) | |
| .find(el => el.dataset.type === 'nipple' && Math.abs(parseFloat(el.style.left) - nipple.x) < 10); | |
| if (element) { | |
| const containerRect = document.getElementById('landmark-container').getBoundingClientRect(); | |
| const yPos = (avgY * (containerRect.height / canvas.height)) + 'px'; | |
| element.style.top = yPos; | |
| } | |
| }); | |
| updateLandmarkPositions(); | |
| alert('Breasts aligned horizontally based on nipple positions!'); | |
| } | |
| }); | |
| // Export functionality | |
| document.getElementById('export-json').addEventListener('click', function() { | |
| const data = { | |
| image: currentImage ? currentImage.getSrc() : null, | |
| landmarks: landmarks, | |
| timestamp: new Date().toISOString() | |
| }; | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'breast-alignment-' + new Date().getTime() + '.json'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| }); | |
| document.getElementById('export-image').addEventListener('click', function() { | |
| if (!currentImage) return; | |
| // Create a temporary canvas with landmarks | |
| const tempCanvas = document.createElement('canvas'); | |
| tempCanvas.width = canvas.width; | |
| tempCanvas.height = canvas.height; | |
| const ctx = tempCanvas.getContext('2d'); | |
| // Draw original image | |
| ctx.drawImage(currentImage.getElement(), 0, 0, canvas.width, canvas.height); | |
| // Draw landmarks | |
| landmarks.forEach(landmark => { | |
| ctx.beginPath(); | |
| ctx.arc(landmark.x, landmark.y, 10, 0, Math.PI * 2); | |
| switch(landmark.type) { | |
| case 'nipple': ctx.fillStyle = 'rgba(236, 72, 153, 0.7)'; break; | |
| case 'underbust': ctx.fillStyle = 'rgba(168, 85, 247, 0.7)'; break; | |
| case 'side': ctx.fillStyle = 'rgba(99, 102, 241, 0.7)'; break; | |
| } | |
| ctx.fill(); | |
| ctx.strokeStyle = 'white'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| }); | |
| // Export | |
| const url = tempCanvas.toDataURL('image/png'); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'breast-alignment-' + new Date().getTime() + '.png'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| }); | |
| }); | |
| </script> | |
| <script>feather.replace();</script> | |
| </body> | |
| </html> | |