Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Image Space Analyzer with TF.js</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- TensorFlow.js and COCO-SSD model --> | |
| <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest/dist/tf.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/coco-ssd@latest/dist/coco-ssd.min.js"></script> | |
| <style> | |
| body { display: flex; flex-direction: column; min-height: 100vh; font-family: sans-serif; } | |
| main { flex-grow: 1; display: flex; flex-direction: column; /* Mobile first */ } | |
| @media (min-width: 768px) { /* md breakpoint */ | |
| main { flex-direction: row; } | |
| } | |
| #imageSection { /* On md and up, takes up ~70% */ } | |
| #analysisSection { /* On md and up, takes up ~30% */ } | |
| #imageDisplayContainer { position: relative; margin: auto; line-height: 0; } | |
| #imageCanvas, #selectionCanvas { position: absolute; top: 0; left: 0; display: block; } | |
| #selectionCanvas { z-index: 10; cursor: crosshair; } | |
| #imageWrapper { | |
| max-height: 80vh; display: flex; justify-content: center; align-items: center; | |
| overflow: hidden; background-color: #e2e8f0; /* bg-gray-300 */ | |
| } | |
| .loader { | |
| border: 5px solid #f3f3f3; border-top: 5px solid #3498db; | |
| border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; | |
| margin: 20px auto; | |
| } | |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100"> | |
| <header class="bg-indigo-700 text-white p-4 shadow-md"> | |
| <h1 class="text-2xl sm:text-3xl font-bold text-center">Image Space Analyzer with TF.js</h1> | |
| </header> | |
| <main class="container mx-auto mt-4 p-0 sm:p-2 md:p-4 flex-grow w-full max-w-7xl"> | |
| <!-- Left Section: Image Input and Display --> | |
| <section id="imageSection" class="w-full md:w-7/12 lg:w-8/12 p-2 md:pr-4 flex flex-col"> | |
| <div class="bg-white shadow-lg rounded-lg p-4 sm:p-6 mb-4"> | |
| <label for="imageUrlInput" class="block text-sm font-medium text-gray-700 mb-1">Image URL:</label> | |
| <div class="flex"> | |
| <input type="url" id="imageUrlInput" name="imageUrlInput" | |
| class="flex-grow p-2 border border-gray-300 rounded-l-md focus:ring-indigo-500 focus:border-indigo-500 text-sm" | |
| placeholder="Enter image URL"> | |
| <button id="loadImageBtn" | |
| class="bg-indigo-600 text-white px-3 sm:px-4 py-2 rounded-r-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 text-sm"> | |
| Load URL | |
| </button> | |
| </div> | |
| <div class="mt-2 text-sm"> | |
| <label for="imageUploadInput" class="text-indigo-600 hover:text-indigo-500 cursor-pointer font-medium"> | |
| Or upload an image from your device | |
| </label> | |
| <input type="file" id="imageUploadInput" accept="image/*" class="hidden"> | |
| </div> | |
| <p id="imageLoadError" class="text-red-600 text-sm mt-2 font-medium"></p> | |
| </div> | |
| <div id="imageWrapper" class="bg-gray-200 rounded-lg flex justify-center items-center flex-grow" style="min-height: 300px;"> | |
| <div id="imageDisplayContainer" class="hidden"> | |
| <canvas id="imageCanvas"></canvas> | |
| <canvas id="selectionCanvas"></canvas> | |
| </div> | |
| <p id="imagePlaceholder" class="text-gray-500 p-4 text-center">Load an image to start selection.</p> | |
| </div> | |
| </section> | |
| <!-- Right Section: Controls and Analysis Results --> | |
| <section id="analysisSection" class="w-full md:w-5/12 lg:w-4/12 p-2 md:pl-0"> | |
| <div class="bg-white shadow-lg rounded-lg p-4 sm:p-6 sticky top-4"> | |
| <h2 class="text-xl font-semibold text-gray-700 mb-4">Controls & Analysis:</h2> | |
| <div id="tfControls" class="mb-4 hidden"> | |
| <h3 class="text-md font-semibold text-gray-600 mb-2">Object Detection (TF.js)</h3> | |
| <button id="detectObjectsBtn" class="w-full bg-teal-500 text-white px-4 py-2 rounded-md hover:bg-teal-600 focus:outline-none text-sm mb-2"> | |
| Detect Objects | |
| </button> | |
| <div id="tfModelStatus" class="text-xs text-gray-500 text-center">Loading TF.js model... <div class="loader inline-block w-4 h-4 border-2 border-t-teal-500"></div></div> | |
| <label class="flex items-center mt-1 text-xs text-gray-600"> | |
| <input type="checkbox" id="mergeDetectionsCheckbox" class="form-checkbox h-4 w-4 text-teal-600 mr-2" checked> | |
| Merge with existing manual selections | |
| </label> | |
| <label class="flex items-center mt-1 text-xs text-gray-600"> | |
| Min. Confidence: <input type="number" id="minConfidenceInput" value="0.5" min="0.1" max="1.0" step="0.1" class="ml-1 p-0.5 border rounded w-16 text-xs"> | |
| </label> | |
| </div> | |
| <div class="mb-4"> | |
| <h3 class="text-md font-semibold text-gray-600 mb-2">Selection Management</h3> | |
| <button id="clearAllSelectionsBtn" class="w-full bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none text-sm mb-2 hidden">Clear All Selections</button> | |
| <button id="deleteActiveSelectionBtn" class="w-full bg-yellow-500 text-black px-4 py-2 rounded-md hover:bg-yellow-600 focus:outline-none text-sm hidden">Delete Active Selection</button> | |
| </div> | |
| <div id="resultsPanel" class="space-y-3"> | |
| <h3 class="text-md font-semibold text-gray-600 mb-2">Space Analysis</h3> | |
| <div> | |
| <p class="text-gray-600 text-sm">Total Image Area:</p> | |
| <p id="totalAreaDisp" class="text-lg font-medium text-gray-800">- px²</p> | |
| </div> | |
| <div> | |
| <p class="text-gray-600 text-sm">Total Selected Area:</p> | |
| <p id="selectedAreaDisp" class="text-lg font-medium text-gray-800">- px²</p> | |
| </div> | |
| <div> | |
| <p class="text-gray-600 text-sm">Selected Region (% of Total):</p> | |
| <p id="percentageSelectedDisp" class="text-lg font-medium text-indigo-600">- %</p> | |
| </div> | |
| <div> | |
| <p class="text-gray-600 text-sm">Extra Space (% of Total):</p> | |
| <p id="percentageExtraDisp" class="text-lg font-medium text-green-600">- %</p> | |
| </div> | |
| <div> | |
| <p class="text-gray-600 text-sm">Number of Selections:</p> | |
| <p id="numSelectionsDisp" class="text-lg font-medium text-gray-800">0</p> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <script> | |
| // --- Global State & Config --- | |
| let cocoSsdModel = null; | |
| let currentImage = null; // HTMLImageElement | |
| let originalImgWidth = 0; | |
| let originalImgHeight = 0; | |
| let displayWidth = 0; // Actual width of canvas on screen | |
| let displayHeight = 0; // Actual height of canvas on screen | |
| let canvasScaleFactor = 1; // displayWidth / originalImgWidth | |
| let selections = []; // Array of selection objects: { id, x, y, width, height, originalAspectRatio, type, label } | |
| let nextSelectionId = 0; | |
| let activeSelectionId = null; | |
| let isDrawingNewSelection = false; | |
| let currentDrawingRect = null; // {startX, startY, endX, endY} for new selection | |
| let dragMode = null; // 'move', 'resize-tl', etc. | |
| let dragStartPoint = null; // {x, y} on canvas | |
| let dragSelectionInitialState = null; | |
| const HANDLE_SIZE = 8; | |
| const HANDLE_INTERACTION_RADIUS = HANDLE_SIZE + 3; // Slightly larger for easier click/hover | |
| // --- DOM Elements --- | |
| const imageUrlInputEl = document.getElementById('imageUrlInput'); | |
| const loadImageBtnEl = document.getElementById('loadImageBtn'); | |
| const imageUploadInputEl = document.getElementById('imageUploadInput'); | |
| const imageLoadErrorEl = document.getElementById('imageLoadError'); | |
| const imageWrapperEl = document.getElementById('imageWrapper'); | |
| const imageDisplayContainerEl = document.getElementById('imageDisplayContainer'); | |
| const imageCanvasEl = document.getElementById('imageCanvas'); | |
| const selectionCanvasEl = document.getElementById('selectionCanvas'); | |
| const imagePlaceholderEl = document.getElementById('imagePlaceholder'); | |
| const imageCtx = imageCanvasEl.getContext('2d'); | |
| const selectionCtx = selectionCanvasEl.getContext('2d'); | |
| const tfControlsEl = document.getElementById('tfControls'); | |
| const detectObjectsBtnEl = document.getElementById('detectObjectsBtn'); | |
| const tfModelStatusEl = document.getElementById('tfModelStatus'); | |
| const mergeDetectionsCheckboxEl = document.getElementById('mergeDetectionsCheckbox'); | |
| const minConfidenceInputEl = document.getElementById('minConfidenceInput'); | |
| const clearAllSelectionsBtnEl = document.getElementById('clearAllSelectionsBtn'); | |
| const deleteActiveSelectionBtnEl = document.getElementById('deleteActiveSelectionBtn'); | |
| const totalAreaDispEl = document.getElementById('totalAreaDisp'); | |
| const selectedAreaDispEl = document.getElementById('selectedAreaDisp'); | |
| const percentageSelectedDispEl = document.getElementById('percentageSelectedDisp'); | |
| const percentageExtraDispEl = document.getElementById('percentageExtraDisp'); | |
| const numSelectionsDispEl = document.getElementById('numSelectionsDisp'); | |
| // --- TF.js Model Loading --- | |
| async function loadCocoSsdModel() { | |
| try { | |
| tfModelStatusEl.innerHTML = 'Loading TF.js COCO-SSD model... <div class="loader inline-block w-4 h-4 border-2 border-t-teal-500"></div>'; | |
| detectObjectsBtnEl.disabled = true; | |
| cocoSsdModel = await cocoSsd.load(); | |
| tfModelStatusEl.textContent = 'TF.js Model Ready.'; | |
| detectObjectsBtnEl.disabled = false; | |
| console.log('COCO-SSD model loaded.'); | |
| } catch (error) { | |
| console.error('Error loading COCO-SSD model:', error); | |
| tfModelStatusEl.textContent = 'Error loading TF.js model.'; | |
| tfModelStatusEl.classList.add('text-red-500'); | |
| } | |
| } | |
| // --- Image Handling --- | |
| function displayImageLoadError(message) { | |
| imageLoadErrorEl.textContent = message; | |
| } | |
| function clearImageLoadError() { | |
| imageLoadErrorEl.textContent = ''; | |
| } | |
| function resetImageArea() { | |
| currentImage = null; | |
| originalImgWidth = 0; originalImgHeight = 0; | |
| displayWidth = 0; displayHeight = 0; | |
| imageDisplayContainerEl.classList.add('hidden'); | |
| imagePlaceholderEl.classList.remove('hidden'); | |
| imagePlaceholderEl.textContent = 'Load an image to start selection.'; | |
| tfControlsEl.classList.add('hidden'); | |
| clearAllSelections(); // Also updates analysis | |
| } | |
| function loadImageFromUrl(url) { | |
| clearImageLoadError(); | |
| const img = new Image(); | |
| img.crossOrigin = "Anonymous"; // Crucial for drawing external images to canvas for TF.js | |
| img.onload = () => { | |
| currentImage = img; | |
| setupImageAndCanvases(); | |
| }; | |
| img.onerror = () => { | |
| displayImageLoadError('Failed to load image from URL. Check URL and CORS policy.'); | |
| resetImageArea(); | |
| }; | |
| img.src = url; | |
| } | |
| function loadImageFromFile(file) { | |
| clearImageLoadError(); | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| currentImage = img; | |
| setupImageAndCanvases(); | |
| }; | |
| img.onerror = () => { | |
| displayImageLoadError('Failed to process uploaded image file.'); | |
| resetImageArea(); | |
| }; | |
| img.src = e.target.result; | |
| }; | |
| reader.onerror = () => { | |
| displayImageLoadError('Failed to read file.'); | |
| resetImageArea(); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function setupImageAndCanvases() { | |
| if (!currentImage) return; | |
| originalImgWidth = currentImage.naturalWidth; | |
| originalImgHeight = currentImage.naturalHeight; | |
| const wrapperStyles = getComputedStyle(imageWrapperEl); | |
| const maxDisplayWidth = imageWrapperEl.clientWidth - parseFloat(wrapperStyles.paddingLeft) - parseFloat(wrapperStyles.paddingRight); | |
| const maxAllowedDisplayHeight = window.innerHeight * 0.80; | |
| let scaleX = maxDisplayWidth / originalImgWidth; | |
| let scaleY = maxAllowedDisplayHeight / originalImgHeight; | |
| canvasScaleFactor = Math.min(1, scaleX, scaleY); // Don't scale up | |
| displayWidth = originalImgWidth * canvasScaleFactor; | |
| displayHeight = originalImgHeight * canvasScaleFactor; | |
| imageCanvasEl.width = displayWidth; | |
| imageCanvasEl.height = displayHeight; | |
| selectionCanvasEl.width = displayWidth; | |
| selectionCanvasEl.height = displayHeight; | |
| imageDisplayContainerEl.style.width = `${displayWidth}px`; | |
| imageDisplayContainerEl.style.height = `${displayHeight}px`; | |
| imageCtx.drawImage(currentImage, 0, 0, displayWidth, displayHeight); | |
| imagePlaceholderEl.classList.add('hidden'); | |
| imageDisplayContainerEl.classList.remove('hidden'); | |
| tfControlsEl.classList.remove('hidden'); | |
| clearAllSelections(); // Clear selections from any previous image and update analysis | |
| updateAnalysisDisplay(); // Ensure total area is shown | |
| } | |
| // --- Selection Logic (similar to previous JS, adapted) --- | |
| function getMousePos(canvas, evt) { | |
| const rect = canvas.getBoundingClientRect(); | |
| return { x: evt.clientX - rect.left, y: evt.clientY - rect.top }; | |
| } | |
| selectionCanvasEl.addEventListener('mousedown', (e) => { | |
| if (!currentImage) return; | |
| const pos = getMousePos(selectionCanvasEl, e); | |
| dragStartPoint = pos; | |
| for (let i = selections.length - 1; i >= 0; i--) { // Check active first, then others | |
| const sel = selections[i]; | |
| const handleType = (activeSelectionId === sel.id) ? getHandleAtPos(pos, sel) : null; // Only check handles of active | |
| if (handleType) { | |
| // activeSelectionId is already sel.id | |
| dragMode = handleType; | |
| dragSelectionInitialState = { ...sel }; | |
| isDrawingNewSelection = false; currentDrawingRect = null; | |
| drawSelections(); return; | |
| } | |
| if (isPointInRect(pos, sel)) { | |
| activeSelectionId = sel.id; // Make this selection active | |
| dragMode = 'move'; | |
| dragSelectionInitialState = { ...sel }; | |
| isDrawingNewSelection = false; currentDrawingRect = null; | |
| // Optional: Bring to front (visual only, if needed for overlap) | |
| // selections.push(selections.splice(selections.findIndex(s => s.id === sel.id), 1)[0]); | |
| drawSelections(); return; | |
| } | |
| } | |
| isDrawingNewSelection = true; | |
| currentDrawingRect = { startX: pos.x, startY: pos.y, endX: pos.x, endY: pos.y }; | |
| activeSelectionId = null; // New drawing deactivates others | |
| dragMode = null; | |
| drawSelections(); | |
| }); | |
| selectionCanvasEl.addEventListener('mousemove', (e) => { | |
| if (!currentImage) return; | |
| const pos = getMousePos(selectionCanvasEl, e); | |
| if (isDrawingNewSelection && currentDrawingRect) { | |
| currentDrawingRect.endX = pos.x; | |
| currentDrawingRect.endY = pos.y; | |
| } else if (dragMode && activeSelectionId !== null && dragSelectionInitialState) { | |
| const activeSel = selections.find(s => s.id === activeSelectionId); | |
| if (!activeSel) return; | |
| const dx = pos.x - dragStartPoint.x; | |
| const dy = pos.y - dragStartPoint.y; | |
| let { x: newX, y: newY, width: newWidth, height: newHeight } = dragSelectionInitialState; | |
| if (dragMode === 'move') { | |
| newX += dx; newY += dy; | |
| } else { // Resizing | |
| const orig = dragSelectionInitialState; | |
| const aspect = orig.originalAspectRatio; | |
| if (dragMode.includes('l')) { newWidth = orig.width - dx; newX = orig.x + dx; } | |
| if (dragMode.includes('r')) { newWidth = orig.width + dx; } | |
| if (dragMode.includes('t')) { newHeight = orig.height - dy; newY = orig.y + dy; } | |
| if (dragMode.includes('b')) { newHeight = orig.height + dy; } | |
| if (dragMode === 'resize-tl' || dragMode === 'resize-tr' || dragMode === 'resize-bl' || dragMode === 'resize-br') { | |
| if (newWidth / (newHeight + 1e-6) > aspect) newWidth = newHeight * aspect; | |
| else newHeight = newWidth / (aspect + 1e-6); | |
| if (dragMode.includes('l')) newX = orig.x + (orig.width - newWidth); | |
| if (dragMode.includes('t')) newY = orig.y + (orig.height - newHeight); | |
| } | |
| } | |
| if (newWidth < 0) { newX += newWidth; newWidth = -newWidth; } | |
| if (newHeight < 0) { newY += newHeight; newHeight = -newHeight; } | |
| // Constrain to canvas bounds | |
| if (dragMode === 'move') { | |
| newX = Math.max(0, Math.min(newX, displayWidth - newWidth)); | |
| newY = Math.max(0, Math.min(newY, displayHeight - newHeight)); | |
| } else { // Resizing boundary check | |
| if (newX < 0) { newWidth += newX; newX = 0; } | |
| if (newY < 0) { newHeight += newY; newY = 0; } | |
| if (newX + newWidth > displayWidth) newWidth = displayWidth - newX; | |
| if (newY + newHeight > displayHeight) newHeight = displayHeight - newY; | |
| // Simplified aspect re-apply after boundary constraint for corners | |
| if ((dragMode === 'resize-tl' || dragMode === 'resize-tr' || dragMode === 'resize-bl' || dragMode === 'resize-br') && newWidth > 0 && newHeight > 0) { | |
| const currentAspect = newWidth / (newHeight + 1e-6); | |
| const originalAspect = dragSelectionInitialState.originalAspectRatio; | |
| if (Math.abs(currentAspect - originalAspect) > 0.01) { | |
| if (dragMode.includes('l') || dragMode.includes('r')) newHeight = newWidth / (originalAspect + 1e-6); | |
| else newWidth = newHeight * originalAspect; | |
| if (dragMode.includes('l')) newX = dragSelectionInitialState.x + dragSelectionInitialState.width - newWidth; | |
| if (dragMode.includes('t')) newY = dragSelectionInitialState.y + dragSelectionInitialState.height - newHeight; | |
| // Final boundary re-check | |
| if (newX < 0) { newWidth += newX; newX = 0; } | |
| if (newY < 0) { newHeight += newY; newY = 0; } | |
| if (newX + newWidth > displayWidth) newWidth = displayWidth - newX; | |
| if (newY + newHeight > displayHeight) newHeight = displayHeight - newY; | |
| } | |
| } | |
| } | |
| activeSel.x = newX; activeSel.y = newY; | |
| activeSel.width = Math.max(0, newWidth); activeSel.height = Math.max(0, newHeight); | |
| } else { | |
| updateCursorStyle(pos); | |
| } | |
| drawSelections(); | |
| if (dragMode || isDrawingNewSelection) updateAnalysisDisplay(); | |
| }); | |
| selectionCanvasEl.addEventListener('mouseup', (e) => { | |
| if (!currentImage) return; | |
| if (isDrawingNewSelection && currentDrawingRect) { | |
| let x = Math.min(currentDrawingRect.startX, currentDrawingRect.endX); | |
| let y = Math.min(currentDrawingRect.startY, currentDrawingRect.endY); | |
| let width = Math.abs(currentDrawingRect.startX - currentDrawingRect.endX); | |
| let height = Math.abs(currentDrawingRect.startY - currentDrawingRect.endY); | |
| if (width > HANDLE_SIZE / 2 && height > HANDLE_SIZE / 2) { | |
| const newId = nextSelectionId++; | |
| selections.push({ | |
| id: newId, x, y, width, height, | |
| originalAspectRatio: width / (height + 1e-6), | |
| type: 'manual', label: `Manual ${newId}` | |
| }); | |
| activeSelectionId = newId; | |
| } | |
| } else if (dragMode && activeSelectionId !== null) { | |
| const activeSel = selections.find(s => s.id === activeSelectionId); | |
| if (activeSel) { | |
| // Update aspect ratio if edge drag changed it | |
| if (!dragMode.startsWith('resize-t') && !dragMode.startsWith('resize-b') && !dragMode.startsWith('resize-l') && !dragMode.startsWith('resize-r')) { | |
| // This is for corner drags or move. For edge drags: | |
| if (dragMode === 'resize-t' || dragMode === 'resize-b' || dragMode === 'resize-l' || dragMode === 'resize-r') { | |
| if (activeSel.width > 0 && activeSel.height > 0) activeSel.originalAspectRatio = activeSel.width / (activeSel.height + 1e-6); | |
| } | |
| } | |
| if (activeSel.width < HANDLE_SIZE / 2 || activeSel.height < HANDLE_SIZE / 2) { // Delete if too small | |
| deleteSelectionById(activeSelectionId); // This will nullify activeSelectionId | |
| } | |
| } | |
| } | |
| isDrawingNewSelection = false; currentDrawingRect = null; dragMode = null; | |
| updateCursorStyle(getMousePos(selectionCanvasEl, e)); | |
| drawSelections(); | |
| updateAnalysisDisplay(); | |
| updateActionButtonsVisibility(); | |
| }); | |
| selectionCanvasEl.addEventListener('mouseleave', (e) => { | |
| if (isDrawingNewSelection || dragMode) { | |
| selectionCanvasEl.dispatchEvent(new MouseEvent('mouseup', e)); // Finalize action | |
| } | |
| selectionCanvasEl.style.cursor = 'crosshair'; | |
| }); | |
| function getHandlePositions(sel) { /* ... same as previous full JS ... */ | |
| return [ | |
| { type: 'resize-tl', x: sel.x, y: sel.y }, { type: 'resize-t', x: sel.x + sel.width / 2, y: sel.y }, | |
| { type: 'resize-tr', x: sel.x + sel.width, y: sel.y }, { type: 'resize-l', x: sel.x, y: sel.y + sel.height / 2 }, | |
| { type: 'resize-r', x: sel.x + sel.width, y: sel.y + sel.height / 2 }, { type: 'resize-bl', x: sel.x, y: sel.y + sel.height }, | |
| { type: 'resize-b', x: sel.x + sel.width / 2, y: sel.y + sel.height }, { type: 'resize-br', x: sel.x + sel.width, y: sel.y + sel.height }, | |
| ]; | |
| } | |
| function getHandleAtPos(pos, sel) { /* ... same as previous full JS ... */ | |
| const handles = getHandlePositions(sel); | |
| for (const handle of handles) { | |
| if (pos.x >= handle.x - HANDLE_INTERACTION_RADIUS && pos.x <= handle.x + HANDLE_INTERACTION_RADIUS && | |
| pos.y >= handle.y - HANDLE_INTERACTION_RADIUS && pos.y <= handle.y + HANDLE_INTERACTION_RADIUS) { | |
| return handle.type; | |
| } | |
| } | |
| return null; | |
| } | |
| function isPointInRect(point, rect) { /* ... same as previous full JS ... */ | |
| return point.x >= rect.x && point.x <= rect.x + rect.width && | |
| point.y >= rect.y && point.y <= rect.y + rect.height; | |
| } | |
| function updateCursorStyle(pos) { /* ... same as previous full JS ... */ | |
| if (!currentImage) { selectionCanvasEl.style.cursor = 'default'; return; } | |
| if (dragMode) { | |
| if (dragMode === 'move') selectionCanvasEl.style.cursor = 'grabbing'; | |
| else if (dragMode === 'resize-tl' || dragMode === 'resize-br') selectionCanvasEl.style.cursor = 'nwse-resize'; | |
| else if (dragMode === 'resize-tr' || dragMode === 'resize-bl') selectionCanvasEl.style.cursor = 'nesw-resize'; | |
| else if (dragMode === 'resize-t' || dragMode === 'resize-b') selectionCanvasEl.style.cursor = 'ns-resize'; | |
| else if (dragMode === 'resize-l' || dragMode === 'resize-r') selectionCanvasEl.style.cursor = 'ew-resize'; | |
| return; | |
| } | |
| const activeSel = selections.find(s => s.id === activeSelectionId); | |
| if (activeSel) { | |
| const handleType = getHandleAtPos(pos, activeSel); | |
| if (handleType) { | |
| if (handleType.startsWith('resize-t') || handleType.startsWith('resize-b')) selectionCanvasEl.style.cursor = 'ns-resize'; | |
| else if (handleType.startsWith('resize-l') || handleType.startsWith('resize-r')) selectionCanvasEl.style.cursor = 'ew-resize'; | |
| else if (handleType === 'resize-tl' || handleType === 'resize-br') selectionCanvasEl.style.cursor = 'nwse-resize'; | |
| else if (handleType === 'resize-tr' || handleType === 'resize-bl') selectionCanvasEl.style.cursor = 'nesw-resize'; | |
| return; | |
| } | |
| if (isPointInRect(pos, activeSel)) { selectionCanvasEl.style.cursor = 'move'; return; } | |
| } | |
| for (const sel of selections) { // Check hover over non-active selections | |
| if (sel.id !== activeSelectionId && isPointInRect(pos, sel)) { selectionCanvasEl.style.cursor = 'pointer'; return; } | |
| } | |
| selectionCanvasEl.style.cursor = 'crosshair'; | |
| } | |
| // --- Drawing --- | |
| function drawSelections() { | |
| selectionCtx.clearRect(0, 0, displayWidth, displayHeight); | |
| selections.forEach(sel => { | |
| selectionCtx.strokeStyle = (sel.id === activeSelectionId) ? 'blue' : (sel.type === 'tfjs' ? 'green' : 'rgba(0, 123, 255, 0.9)'); | |
| selectionCtx.fillStyle = (sel.id === activeSelectionId) ? 'rgba(0, 123, 255, 0.3)' : (sel.type === 'tfjs' ? 'rgba(0,255,0,0.2)' : 'rgba(0, 123, 255, 0.2)'); | |
| selectionCtx.lineWidth = (sel.id === activeSelectionId) ? 2 : 1.5; | |
| selectionCtx.beginPath(); | |
| selectionCtx.rect(sel.x, sel.y, sel.width, sel.height); | |
| selectionCtx.fill(); | |
| selectionCtx.stroke(); | |
| if (sel.label) { | |
| selectionCtx.fillStyle = (sel.id === activeSelectionId) ? 'blue' : (sel.type === 'tfjs' ? 'darkgreen' : 'black'); | |
| selectionCtx.font = "10px Arial"; | |
| const labelX = sel.x + 2; | |
| const labelY = sel.y > 12 ? sel.y + 10 : sel.y + sel.height + 10; // Position label inside or below | |
| selectionCtx.fillText(sel.label, labelX, labelY); | |
| } | |
| if (sel.id === activeSelectionId) drawHandles(sel); | |
| }); | |
| if (isDrawingNewSelection && currentDrawingRect) { | |
| selectionCtx.strokeStyle = 'rgba(0, 200, 0, 0.9)'; | |
| selectionCtx.fillStyle = 'rgba(0, 200, 0, 0.2)'; | |
| selectionCtx.lineWidth = 1.5; | |
| let x = Math.min(currentDrawingRect.startX, currentDrawingRect.endX); | |
| let y = Math.min(currentDrawingRect.startY, currentDrawingRect.endY); | |
| let width = Math.abs(currentDrawingRect.startX - currentDrawingRect.endX); | |
| let height = Math.abs(currentDrawingRect.startY - currentDrawingRect.endY); | |
| selectionCtx.beginPath(); | |
| selectionCtx.rect(x, y, width, height); | |
| selectionCtx.fill(); | |
| selectionCtx.stroke(); | |
| } | |
| } | |
| function drawHandles(sel) { /* ... same as previous full JS ... */ | |
| const handles = getHandlePositions(sel); | |
| selectionCtx.fillStyle = 'blue'; // Solid blue for handles | |
| selectionCtx.strokeStyle = 'white'; | |
| selectionCtx.lineWidth = 1; | |
| handles.forEach(h => { | |
| selectionCtx.beginPath(); | |
| selectionCtx.rect(h.x - HANDLE_SIZE / 2, h.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); | |
| selectionCtx.fill(); | |
| selectionCtx.stroke(); | |
| }); | |
| } | |
| // --- TF.js Object Detection --- | |
| detectObjectsBtnEl.addEventListener('click', async () => { | |
| if (!currentImage || !cocoSsdModel) { | |
| alert('Please load an image and wait for the TF.js model to be ready.'); | |
| return; | |
| } | |
| detectObjectsBtnEl.disabled = true; | |
| detectObjectsBtnEl.innerHTML = 'Detecting... <div class="loader inline-block w-4 h-4 border-2 border-t-white"></div>'; | |
| try { | |
| // COCO-SSD expects an HTMLImageElement, HTMLVideoElement, HTMLCanvasElement, or ImageData | |
| // We can pass the original HTMLImageElement `currentImage` or the `imageCanvasEl` | |
| // Passing `currentImage` is usually better as it's the original data. | |
| const predictions = await cocoSsdModel.detect(currentImage); | |
| if (!mergeDetectionsCheckboxEl.checked) { // Remove previous TF.js detections | |
| selections = selections.filter(s => s.type !== 'tfjs'); | |
| } | |
| const minConfidence = parseFloat(minConfidenceInputEl.value) || 0.5; | |
| predictions.forEach(p => { | |
| if (p.score >= minConfidence) { | |
| const [xOrig, yOrig, widthOrig, heightOrig] = p.bbox; | |
| // Convert original image coordinates to display canvas coordinates | |
| const dispX = xOrig * canvasScaleFactor; | |
| const dispY = yOrig * canvasScaleFactor; | |
| const dispWidth = widthOrig * canvasScaleFactor; | |
| const dispHeight = heightOrig * canvasScaleFactor; | |
| selections.push({ | |
| id: nextSelectionId++, | |
| x: dispX, y: dispY, width: dispWidth, height: dispHeight, | |
| originalAspectRatio: dispWidth / (dispHeight + 1e-6), | |
| type: 'tfjs', | |
| label: `${p.class} (${Math.round(p.score * 100)}%)` | |
| }); | |
| } | |
| }); | |
| activeSelectionId = null; // Deactivate any current selection after adding new ones | |
| } catch (error) { | |
| console.error('Error during TF.js detection:', error); | |
| alert('An error occurred during object detection.'); | |
| } finally { | |
| detectObjectsBtnEl.disabled = false; | |
| detectObjectsBtnEl.textContent = 'Detect Objects'; | |
| drawSelections(); | |
| updateAnalysisDisplay(); | |
| updateActionButtonsVisibility(); | |
| } | |
| }); | |
| // --- Selection Management & Analysis --- | |
| function deleteSelectionById(idToDelete) { | |
| selections = selections.filter(sel => sel.id !== idToDelete); | |
| if (activeSelectionId === idToDelete) { | |
| activeSelectionId = null; | |
| } | |
| // No need to redraw/update here, caller will do it | |
| } | |
| deleteActiveSelectionBtnEl.addEventListener('click', () => { | |
| if (activeSelectionId !== null) { | |
| deleteSelectionById(activeSelectionId); | |
| // activeSelectionId is now null | |
| drawSelections(); | |
| updateAnalysisDisplay(); | |
| updateActionButtonsVisibility(); | |
| } | |
| }); | |
| clearAllSelectionsBtnEl.addEventListener('click', clearAllSelections); | |
| function clearAllSelections() { | |
| selections = []; | |
| activeSelectionId = null; | |
| // nextSelectionId can be reset if desired, or keep incrementing for true uniqueness over session | |
| if (selectionCtx && displayWidth > 0) drawSelections(); // Clears canvas | |
| updateAnalysisDisplay(); | |
| updateActionButtonsVisibility(); | |
| } | |
| function updateActionButtonsVisibility() { | |
| clearAllSelectionsBtnEl.classList.toggle('hidden', selections.length === 0); | |
| deleteActiveSelectionBtnEl.classList.toggle('hidden', activeSelectionId === null || selections.length === 0); | |
| } | |
| function updateAnalysisDisplay() { | |
| if (!currentImage) { | |
| totalAreaDispEl.textContent = '- px²'; | |
| selectedAreaDispEl.textContent = '- px²'; | |
| percentageSelectedDispEl.textContent = '- %'; | |
| percentageExtraDispEl.textContent = '- %'; | |
| numSelectionsDispEl.textContent = '0'; | |
| return; | |
| } | |
| const totalImgAreaPx = originalImgWidth * originalImgHeight; | |
| totalAreaDispEl.textContent = `${Math.round(totalImgAreaPx).toLocaleString()} px²`; | |
| let totalSelectedOriginalPxArea = 0; | |
| selections.forEach(sel => { | |
| // Convert selection display dimensions back to original image pixel dimensions | |
| const originalSelWidth = sel.width / canvasScaleFactor; | |
| const originalSelHeight = sel.height / canvasScaleFactor; | |
| totalSelectedOriginalPxArea += originalSelWidth * originalSelHeight; | |
| }); | |
| selectedAreaDispEl.textContent = `${Math.round(totalSelectedOriginalPxArea).toLocaleString()} px²`; | |
| numSelectionsDispEl.textContent = selections.length.toString(); | |
| if (totalImgAreaPx > 0 && totalSelectedOriginalPxArea >= 0) { | |
| const percSelected = (totalSelectedOriginalPxArea / totalImgAreaPx) * 100; | |
| const percExtra = 100 - percSelected; | |
| percentageSelectedDispEl.textContent = `${percSelected.toFixed(1)} %`; | |
| percentageExtraDispEl.textContent = `${percExtra.toFixed(1)} %`; | |
| } else { | |
| percentageSelectedDispEl.textContent = '0.0 %'; | |
| percentageExtraDispEl.textContent = '100.0 %'; | |
| } | |
| } | |
| // --- Event Listeners for Image Load --- | |
| loadImageBtnEl.addEventListener('click', () => { | |
| const url = imageUrlInputEl.value.trim(); | |
| if (url) { | |
| imagePlaceholderEl.textContent = 'Loading image from URL...'; | |
| loadImageFromUrl(url); | |
| } else { | |
| displayImageLoadError('Please enter an image URL.'); | |
| } | |
| }); | |
| imageUploadInputEl.addEventListener('change', (event) => { | |
| const file = event.target.files[0]; | |
| if (file) { | |
| imagePlaceholderEl.textContent = 'Loading image from file...'; | |
| loadImageFromFile(file); | |
| } | |
| event.target.value = null; // Reset file input | |
| }); | |
| // --- Initial Setup --- | |
| document.addEventListener('DOMContentLoaded', () => { | |
| resetImageArea(); // Set initial placeholder state | |
| loadCocoSsdModel(); // Start loading TF.js model immediately | |
| updateActionButtonsVisibility(); // Initial button states | |
| }); | |
| </script> | |
| </body> | |
| </html> |