Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Leaf Segmentation with Unet</title> | |
| <style> | |
| :root { | |
| --primary: #4CAF50; | |
| --dark-bg: #121212; | |
| --surface-bg: #1E1E2E; | |
| --text-primary: #E0E0E0; | |
| --text-secondary: #9E9E9E; | |
| --border-color: rgba(255, 255, 255, 0.1); | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: 'Segoe UI', sans-serif; | |
| background: var(--dark-bg); | |
| color: var(--text-primary); | |
| padding: 20px; | |
| } | |
| .container { | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| background: var(--surface-bg); | |
| border-radius: 16px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.3); | |
| border: 1px solid var(--border-color); | |
| } | |
| .header-note { | |
| font-size: 0.9rem; | |
| background-color: rgba(255, 107, 53, 0.1); | |
| color: #F7931E; /* Orange color to stand out */ | |
| padding: 10px 15px; | |
| border-radius: 6px; | |
| max-width: 800px; | |
| margin: 0 auto; | |
| border: 1px solid rgba(255, 107, 53, 0.3); | |
| } | |
| .tool-instruction { | |
| font-size: 0.85rem; | |
| color: var(--text-secondary); | |
| margin-bottom: 15px; | |
| text-align: center; | |
| } | |
| h1 { | |
| text-align: center; | |
| color: #66BB6A; /* Brighter green for better visibility */ | |
| margin-bottom: 15px; /* Reduced bottom margin */ | |
| font-size: 2.5em; | |
| font-weight: 600; /* Increased font weight to make it bold */ | |
| text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); | |
| } | |
| .main-content { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 30px; | |
| padding: 0 30px 30px 30px; | |
| } | |
| .column { | |
| background: rgba(0,0,0,0.2); | |
| border-radius: 12px; | |
| padding: 25px; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .column h2 { | |
| color: var(--primary); | |
| margin-bottom: 20px; | |
| font-size: 1.5em; | |
| font-weight: 500; | |
| text-align: center; | |
| } | |
| .upload-area { | |
| border: 2px dashed var(--primary); | |
| border-radius: 8px; | |
| padding: 30px; | |
| text-align: center; | |
| margin-bottom: 20px; | |
| transition: all 0.3s ease; | |
| cursor: pointer; | |
| user-select: none; | |
| } | |
| .upload-area:hover, .upload-area.dragover { | |
| background: rgba(76, 175, 80, 0.1); | |
| border-color: #66BB6A; | |
| } | |
| .canvas-container { | |
| background: #000; | |
| border-radius: 8px; | |
| margin-bottom: 20px; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| height: 400px; | |
| border: 1px solid var(--border-color); | |
| flex-shrink: 0; | |
| position: relative; | |
| } | |
| canvas { | |
| max-width: 100%; | |
| max-height: 100%; | |
| object-fit: contain; | |
| cursor: crosshair; | |
| } | |
| .tools-panel, .output-controls { | |
| background: rgba(0,0,0,0.2); | |
| border-radius: 8px; | |
| padding: 20px; | |
| margin-bottom: 20px; | |
| } | |
| .tool-group { margin-bottom: 15px; } | |
| .tool-group label { | |
| display: block; | |
| margin-bottom: 8px; | |
| font-weight: 500; | |
| color: var(--text-secondary); | |
| } | |
| .tool-buttons { display: flex; gap: 10px; } | |
| .tool-btn { | |
| background: rgba(76, 175, 80, 0.2); | |
| color: var(--primary); | |
| border: 1px solid var(--primary); | |
| padding: 10px 16px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| flex: 1; | |
| } | |
| .tool-btn:hover { background: rgba(76, 175, 80, 0.3); } | |
| .tool-btn.active { background: var(--primary); color: white; } | |
| .predict-btn { | |
| background: linear-gradient(135deg, #FF6B35, #F7931E); | |
| color: white; border: none; | |
| padding: 15px 30px; font-size: 18px; width: 100%; | |
| border-radius: 8px; cursor: pointer; transition: all 0.3s ease; | |
| } | |
| .predict-btn:hover:not(:disabled) { transform: translateY(-2px); } | |
| .predict-btn:disabled { background: #666; cursor: not-allowed; } | |
| .output-tabs { display: flex; gap: 10px; margin-bottom: 20px; } | |
| .download-btn { | |
| background: linear-gradient(135deg, #9C27B0, #673AB7); | |
| color: white; border: none; padding: 12px 24px; | |
| border-radius: 8px; cursor: pointer; width: 100%; | |
| } | |
| .download-btn:disabled { background: #666; cursor: not-allowed; } | |
| .hidden { display: none ; } | |
| /* visually-hidden allows programmatic click while keeping it out of sight */ | |
| .visually-hidden { | |
| position: absolute ; | |
| left: -9999px ; | |
| width: 1px ; | |
| height: 1px ; | |
| overflow: hidden ; | |
| } | |
| @media (max-width: 900px) { | |
| .main-content { grid-template-columns: 1fr; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Leaf Segmentation with Unet</h1> | |
| <p class="header-note"> | |
| <strong>NOTE:</strong> The tool is well-suited only for single-leaf per image. It may fails for in-the-wild images. | |
| </p> | |
| <div class="main-content"> | |
| <div class="column"> | |
| <h2>Input & Controls</h2> | |
| <div class="upload-area" id="upload-area" role="button" tabindex="0"> | |
| <p>Click or Drop Image Here</p> | |
| </div> | |
| <!-- keep file input in DOM but visually hidden (not display:none) so programmatic click works reliably --> | |
| <input type="file" id="file-input" class="visually-hidden" accept="image/*"> | |
| <div class="canvas-container"> | |
| <canvas id="input-canvas"></canvas> | |
| </div> | |
| <div class="tools-panel"> | |
| <div class="tool-group"> | |
| <label>Annotation Tools</label> | |
| <p class="tool-instruction"> | |
| Provide model a hint by making a dot or drawing a short line inside the target leaf. | |
| </p> | |
| <div class="tool-buttons"> | |
| <button class="tool-btn active" data-tool="point">Point (Dot)</button> | |
| <button class="tool-btn" data-tool="line">Line/Drag</button> | |
| </div> | |
| </div> | |
| <button class="tool-btn" id="clear-annotations" style="width:100%; margin-top:10px;">Clear Annotations</button> | |
| </div> | |
| <button class="predict-btn" id="predict-btn" disabled>Predict</button> | |
| </div> | |
| <div class="column"> | |
| <h2>Output</h2> | |
| <div class="output-tabs"> | |
| <button class="tool-btn active" id="view-filled">Filled</button> | |
| <button class="tool-btn" id="view-outline">Outline</button> | |
| <button class="tool-btn" id="view-binary">Binary Mask</button> | |
| </div> | |
| <div class="canvas-container" id="output-container"> | |
| <canvas id="output-canvas"></canvas> | |
| </div> | |
| <div class="output-controls"> | |
| <div class="tool-group" id="transparency-control"> | |
| <label>Mask Transparency</label> | |
| <input type="range" id="transparency" min="0" max="100" value="40" style="width:100%;"> | |
| </div> | |
| <button class="download-btn" id="download-btn" disabled>Download Mask</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script src="https://docs.opencv.org/4.8.0/opencv.js"></script> | |
| <script> | |
| // Robust init: wait for both DOMContentLoaded and cv runtime | |
| let cvReady = false; | |
| let domReady = false; | |
| function tryInit() { | |
| if (cvReady && domReady) { | |
| // instantiate app | |
| window.segApp = new SegmentationApp(); | |
| } | |
| } | |
| document.addEventListener('DOMContentLoaded', () => { domReady = true; tryInit(); }); | |
| cv['onRuntimeInitialized'] = () => { console.log('OpenCV.js is ready.'); cvReady = true; tryInit(); }; | |
| class SegmentationApp { | |
| constructor() { | |
| this.inputCanvas = document.getElementById('input-canvas'); | |
| this.inputCtx = this.inputCanvas.getContext('2d'); | |
| this.outputCanvas = document.getElementById('output-canvas'); | |
| this.outputCtx = this.outputCanvas.getContext('2d'); | |
| this.maskCanvas = document.createElement('canvas'); | |
| this.maskCtx = this.maskCanvas.getContext('2d'); | |
| this.currentTool = 'point'; | |
| this.brushSize = 8; | |
| this.isDrawing = false; | |
| this.uploadedImage = null; | |
| this.predictedMask = null; | |
| this.annotations = []; | |
| this.currentView = 'filled'; | |
| this.predictBtn = document.getElementById('predict-btn'); | |
| this.downloadBtn = document.getElementById('download-btn'); | |
| this.transparencySlider = document.getElementById('transparency'); | |
| this.transparencyControl = document.getElementById('transparency-control'); | |
| this.initEventListeners(); | |
| } | |
| initEventListeners() { | |
| const fileInput = document.getElementById('file-input'); | |
| const uploadArea = document.getElementById('upload-area'); | |
| // clicking upload area triggers the hidden file input | |
| uploadArea.addEventListener('click', () => fileInput.click()); | |
| // also support keyboard activation for accessibility | |
| uploadArea.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } }); | |
| fileInput.addEventListener('change', e => this.handleFile(e.target.files[0])); | |
| 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 f = e.dataTransfer.files && e.dataTransfer.files[0]; | |
| if (f) this.handleFile(f); | |
| }); | |
| this.inputCanvas.addEventListener('mousedown', e => this.startDrawing(e)); | |
| this.inputCanvas.addEventListener('mousemove', e => this.draw(e)); | |
| ['mouseup', 'mouseout'].forEach(evt => this.inputCanvas.addEventListener(evt, () => this.stopDrawing())); | |
| document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => { | |
| btn.onclick = (e) => { | |
| document.querySelectorAll('.tool-btn[data-tool]').forEach(b => b.classList.remove('active')); | |
| e.target.classList.add('active'); | |
| this.currentTool = e.target.dataset.tool; | |
| }; | |
| }); | |
| document.getElementById('view-filled').onclick = () => this.setView('filled'); | |
| document.getElementById('view-outline').onclick = () => this.setView('outline'); | |
| document.getElementById('view-binary').onclick = () => this.setView('binary'); | |
| this.transparencySlider.oninput = () => this.renderOutput(); | |
| document.getElementById('clear-annotations').onclick = () => this.clearAnnotations(); | |
| this.predictBtn.onclick = () => this.predict(); | |
| this.downloadBtn.onclick = () => this.downloadMask(); | |
| // prevent accidental image drag from navigating away | |
| window.addEventListener('dragover', (e) => e.preventDefault()); | |
| window.addEventListener('drop', (e) => e.preventDefault()); | |
| } | |
| handleFile(file) { | |
| if (!file || !file.type.startsWith('image/')) return; | |
| const reader = new FileReader(); | |
| reader.onload = e => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| this.uploadedImage = img; | |
| this.reset(); | |
| this.drawImageScaled(this.inputCanvas, this.inputCtx, img); | |
| this.outputCanvas.width = this.inputCanvas.width; | |
| this.outputCanvas.height = this.inputCanvas.height; | |
| this.maskCanvas.width = this.inputCanvas.width; | |
| this.maskCanvas.height = this.inputCanvas.height; | |
| }; | |
| img.src = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| reset() { | |
| this.annotations = []; | |
| this.predictedMask = null; | |
| this.predictBtn.disabled = true; | |
| this.downloadBtn.disabled = true; | |
| this.outputCtx.clearRect(0,0, this.outputCanvas.width, this.outputCanvas.height); | |
| } | |
| drawImageScaled(canvas, ctx, img) { | |
| const container = canvas.parentElement; | |
| const hRatio = container.clientWidth / img.width; | |
| const vRatio = container.clientHeight / img.height; | |
| const ratio = Math.min(hRatio, vRatio); | |
| // Guard against zero sizes | |
| const finalRatio = (ratio && isFinite(ratio)) ? ratio : 1; | |
| canvas.width = Math.round(img.width * finalRatio); | |
| canvas.height = Math.round(img.height * finalRatio); | |
| ctx.clearRect(0,0,canvas.width, canvas.height); | |
| ctx.drawImage(img, 0, 0, canvas.width, canvas.height); | |
| } | |
| getCanvasCoordinates(e) { | |
| const rect = this.inputCanvas.getBoundingClientRect(); | |
| // Support touch events | |
| const clientX = e.touches ? e.touches[0].clientX : e.clientX; | |
| const clientY = e.touches ? e.touches[0].clientY : e.clientY; | |
| return { | |
| x: (clientX - rect.left) * (this.inputCanvas.width / rect.width), | |
| y: (clientY - rect.top) * (this.inputCanvas.height / rect.height) | |
| }; | |
| } | |
| startDrawing(e) { | |
| if (!this.uploadedImage) return; | |
| this.isDrawing = true; | |
| const coords = this.getCanvasCoordinates(e); | |
| if (this.currentTool === 'point') this.drawOnCanvas(coords.x, coords.y); | |
| else { this.lastX = coords.x; this.lastY = coords.y; } | |
| } | |
| draw(e) { | |
| if (!this.isDrawing || this.currentTool !== 'line') return; | |
| const coords = this.getCanvasCoordinates(e); | |
| this.drawOnCanvas(this.lastX, this.lastY, coords.x, coords.y); | |
| [this.lastX, this.lastY] = [coords.x, coords.y]; | |
| } | |
| stopDrawing() { this.isDrawing = false; } | |
| drawOnCanvas(x1, y1, x2 = null, y2 = null) { | |
| [this.inputCtx, this.maskCtx].forEach((ctx, i) => { | |
| ctx.beginPath(); | |
| ctx.lineWidth = this.brushSize * 2; | |
| ctx.strokeStyle = (i === 0) ? '#4CAF50' : 'white'; | |
| ctx.fillStyle = (i === 0) ? '#4CAF50' : 'white'; | |
| ctx.lineCap = 'round'; | |
| if (x2 !== null && y2 !== null) { | |
| ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); | |
| } else { | |
| ctx.arc(x1, y1, this.brushSize, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| }); | |
| this.annotations.push(true); | |
| this.predictBtn.disabled = false; | |
| } | |
| clearAnnotations() { | |
| if (!this.uploadedImage) return; | |
| this.reset(); | |
| this.drawImageScaled(this.inputCanvas, this.inputCtx, this.uploadedImage); | |
| this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); | |
| } | |
| async predict() { | |
| if (!this.uploadedImage || this.annotations.length === 0) return; | |
| this.predictBtn.disabled = true; this.predictBtn.innerText = "Processing..."; | |
| try { | |
| const payload = { | |
| image: this.inputCanvas.toDataURL('image/jpeg'), | |
| scribble_mask: this.maskCanvas.toDataURL('image/png') | |
| }; | |
| const response = await fetch('/predict', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) throw new Error(`Server error: ${response.status}`); | |
| const result = await response.json(); | |
| const maskImg = new Image(); | |
| maskImg.onload = () => { | |
| this.predictedMask = maskImg; | |
| this.renderOutput(); | |
| this.downloadBtn.disabled = false; | |
| }; | |
| maskImg.src = result.predicted_mask; | |
| } catch (error) { | |
| console.error('Prediction failed:', error); | |
| alert('Prediction failed. Check the console for details.'); | |
| } finally { | |
| this.predictBtn.innerText = "Predict"; | |
| this.predictBtn.disabled = this.annotations.length === 0; | |
| } | |
| } | |
| setView(view) { | |
| this.currentView = view; | |
| document.querySelectorAll('.output-tabs .tool-btn').forEach(b => b.classList.remove('active')); | |
| const el = document.getElementById(`view-${view}`); | |
| if (el) el.classList.add('active'); | |
| this.transparencyControl.style.display = (view === 'filled') ? 'block' : 'none'; | |
| this.renderOutput(); | |
| } | |
| renderOutput() { | |
| if (!this.predictedMask) { | |
| this.outputCtx.clearRect(0, 0, this.outputCanvas.width, this.outputCanvas.height); | |
| return; | |
| } | |
| this.outputCtx.clearRect(0,0, this.outputCanvas.width, this.outputCanvas.height); | |
| this.drawImageScaled(this.outputCanvas, this.outputCtx, this.uploadedImage); | |
| if (this.currentView === 'binary') { | |
| this.drawImageScaled(this.outputCanvas, this.outputCtx, this.predictedMask); | |
| return; | |
| } | |
| const tempCanvas = document.createElement('canvas'); | |
| const tempCtx = tempCanvas.getContext('2d'); | |
| tempCanvas.width = this.outputCanvas.width; | |
| tempCanvas.height = this.outputCanvas.height; | |
| tempCtx.drawImage(this.predictedMask, 0, 0, tempCanvas.width, tempCanvas.height); | |
| // if (this.currentView === 'filled') { | |
| // tempCtx.globalCompositeOperation = 'source-in'; | |
| // tempCtx.fillStyle = '#FF00FF'; // Magenta | |
| // tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); | |
| // this.outputCtx.globalAlpha = this.transparencySlider.value / 100; | |
| // this.outputCtx.drawImage(tempCanvas, 0, 0); | |
| // this.outputCtx.globalAlpha = 1.0; | |
| if (this.currentView === 'filled') { | |
| // --- FIX START: Correct way to create a colored overlay --- | |
| const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); | |
| const data = imageData.data; | |
| // Iterate through each pixel | |
| for (let i = 0; i < data.length; i += 4) { | |
| // data[i] is red, data[i+3] is alpha (transparency) | |
| // If the pixel in the mask is white (value > 128) | |
| if (data[i] > 128) { | |
| data[i] = 255; // R - Magenta | |
| data[i + 1] = 0; // G | |
| data[i + 2] = 255; // B | |
| data[i + 3] = 255; // Alpha - make it fully opaque | |
| } else { | |
| // If the pixel is black, make it completely transparent | |
| data[i + 3] = 0; | |
| } | |
| } | |
| tempCtx.putImageData(imageData, 0, 0); | |
| // Now draw this corrected overlay onto the main canvas with user-defined transparency | |
| this.outputCtx.globalAlpha = this.transparencySlider.value / 100; | |
| this.outputCtx.drawImage(tempCanvas, 0, 0); | |
| this.outputCtx.globalAlpha = 1.0; // Reset alpha | |
| // --- FIX END --- | |
| } else if (this.currentView === 'outline') { | |
| const src = cv.imread(tempCanvas); | |
| cv.cvtColor(src, src, cv.COLOR_RGBA2GRAY, 0); | |
| // Create a transparent mat to draw contours on | |
| let contourImg = cv.Mat.zeros(src.rows, src.cols, cv.CV_8UC4); | |
| const contours = new cv.MatVector(); | |
| const hierarchy = new cv.Mat(); | |
| cv.findContours(src, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE); | |
| const color = new cv.Scalar(255, 255, 255, 255); // White color | |
| for (let i = 0; i < contours.size(); ++i) { | |
| cv.drawContours(contourImg, contours, i, color, 2, cv.LINE_8, hierarchy, 0); | |
| } | |
| // Draw the contours over the original image | |
| // draw contourImg into a temporary HTML canvas first | |
| const contourCanvas = document.createElement('canvas'); | |
| contourCanvas.width = contourImg.cols; | |
| contourCanvas.height = contourImg.rows; | |
| cv.imshow(contourCanvas, contourImg); | |
| this.outputCtx.drawImage(contourCanvas, 0, 0, this.outputCanvas.width, this.outputCanvas.height); | |
| src.delete(); contours.delete(); hierarchy.delete(); contourImg.delete(); | |
| } | |
| } | |
| // downloadMask() { | |
| // if (!this.predictedMask) return; | |
| // const link = document.createElement('a'); | |
| // link.download = 'binary_mask.png'; | |
| // link.href = this.predictedMask.src; | |
| // link.click(); | |
| // } | |
| downloadMask() { | |
| if (!this.predictedMask) return; // No mask, nothing to download | |
| const link = document.createElement('a'); | |
| let filename = 'segmentation_result.png'; | |
| // Determine filename based on current view | |
| if (this.currentView === 'filled') { | |
| filename = 'filled_mask.png'; | |
| } else if (this.currentView === 'outline') { | |
| filename = 'outline_mask.png'; | |
| } else if (this.currentView === 'binary') { | |
| filename = 'binary_mask.png'; | |
| } | |
| // Get the image data from the outputCanvas (which holds the current view) | |
| link.download = filename; | |
| link.href = this.outputCanvas.toDataURL('image/png'); // Gets the current content of the outputCanvas | |
| link.click(); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |