Please bookmark this page to avoid losing your image tool!

Image Sand Dollar Counter

(Free & Supports Bulk Upload)

Drag & drop your images here or

The result will appear here...
You can edit the below JavaScript code to customize the image tool.
function processImage(originalImg, minSizePercent = 0.05, maxSizePercent = 25.0, aspectTolerance = 0.5) {
    const MAX_DIM = 800;
    let scale = 1;
    if (originalImg.width > MAX_DIM || originalImg.height > MAX_DIM) {
        scale = Math.min(MAX_DIM / originalImg.width, MAX_DIM / originalImg.height);
    }
    
    const w = Math.round(originalImg.width * scale);
    const h = Math.round(originalImg.height * scale);
    
    const canvas = document.createElement('canvas');
    canvas.width = w;
    canvas.height = h;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(originalImg, 0, 0, w, h);
    
    let imgData;
    try {
        imgData = ctx.getImageData(0, 0, w, h);
    } catch (e) {
        ctx.fillStyle = "black"; 
        ctx.fillRect(0, 0, w, h);
        ctx.fillStyle = "red"; 
        ctx.font = "20px sans-serif";
        ctx.fillText("Error: Cannot access image data (CORS).", 20, 40);
        return canvas;
    }

    // 1. Convert to Grayscale
    let grayImage = new Uint8Array(w * h);
    for(let i = 0; i < w * h; i++) {
        let r = imgData.data[i*4];
        let g = imgData.data[i*4+1];
        let b = imgData.data[i*4+2];
        grayImage[i] = (r*0.299 + g*0.587 + b*0.114) | 0;
    }

    // 2. Fast Box Blur to remove textures
    function boxBlur(input, passes=1) {
        let curr = input;
        for(let p=0; p<passes; p++) {
            let next = new Uint8Array(w * h);
            for(let y=1; y<h-1; y++){
                for(let x=1; x<w-1; x++){
                    let sum = curr[(y-1)*w + x-1] + curr[(y-1)*w + x] + curr[(y-1)*w + x+1] +
                              curr[y*w + x-1]     + curr[y*w + x]     + curr[y*w + x+1] +
                              curr[(y+1)*w + x-1] + curr[(y+1)*w + x] + curr[(y+1)*w + x+1];
                    next[y*w + x] = (sum / 9) | 0;
                }
            }
            curr = next;
        }
        return curr;
    }
    let blurred = boxBlur(grayImage, 2);

    // 3. Morphological Operations (Erosion and Dilation via Cross Kernel)
    function erode(input) {
        let out = new Uint8Array(w * h);
        for(let y=1; y<h-1; y++){
            for(let x=1; x<w-1; x++){
                let idx = y*w + x;
                if(input[idx] && input[idx-1] && input[idx+1] && input[idx-w] && input[idx+w]) out[idx] = 1;
            }
        }
        return out;
    }
    function dilate(input) {
        let out = new Uint8Array(w * h);
        for(let y=1; y<h-1; y++){
            for(let x=1; x<w-1; x++){
                let idx = y*w + x;
                if(input[idx] || input[idx-1] || input[idx+1] || input[idx-w] || input[idx+w]) out[idx] = 1;
            }
        }
        return out;
    }

    // 4. Multiple Ensemble Object Segmenters (Adaptive Thresholding + Sobel Edge Inv)
    let intImg = new Int32Array(w * h);
    for(let y=0; y<h; y++){
        let sum = 0;
        for(let x=0; x<w; x++){
            sum += blurred[y*w + x];
            if (y === 0) intImg[x] = sum;
            else intImg[y*w + x] = intImg[(y-1)*w + x] + sum;
        }
    }
    
    let binaryBright = new Uint8Array(w * h);
    let binaryDark = new Uint8Array(w * h);
    let s = Math.floor(Math.max(w, h) * 0.2); // 20% window
    let s2 = (s / 2) | 0;
    let C = 7;
    for(let y=0; y<h; y++){
        for(let x=0; x<w; x++){
            let x1 = Math.max(0, x - s2);  let y1 = Math.max(0, y - s2);
            let x2 = Math.min(w - 1, x + s2); let y2 = Math.min(h - 1, y + s2);
            let t1 = intImg[y2 * w + x2];
            let t2 = y1 > 0 ? intImg[(y1 - 1) * w + x2] : 0;
            let t3 = x1 > 0 ? intImg[y2 * w + (x1 - 1)] : 0;
            let t4 = (y1 > 0 && x1 > 0) ? intImg[(y1 - 1) * w + (x1 - 1)] : 0;
            let mean = (t1 - t2 - t3 + t4) / ((y2 - y1 + 1) * (x2 - x1 + 1));
            let val = blurred[y * w + x];
            if (val > mean + C) binaryBright[y*w + x] = 1;
            if (val < mean - C) binaryDark[y*w + x] = 1;
        }
    }
    binaryBright = dilate(erode(binaryBright));
    binaryDark = dilate(erode(binaryDark));

    let edges = new Uint8Array(w * h);
    for(let y=1; y<h-1; y++){
        for(let x=1; x<w-1; x++){
            let px00 = blurred[(y-1)*w + x-1]; let px01 = blurred[(y-1)*w + x]; let px02 = blurred[(y-1)*w + x+1];
            let px10 = blurred[y*w + x-1];                                        let px12 = blurred[y*w + x+1];
            let px20 = blurred[(y+1)*w + x-1]; let px21 = blurred[(y+1)*w + x]; let px22 = blurred[(y+1)*w + x+1];
            
            let gx = (px02 - px00) + 2*(px12 - px10) + (px22 - px20);
            let gy = (px20 - px00) + 2*(px21 - px01) + (px22 - px02);
            if(Math.sqrt(gx*gx + gy*gy) > 60) edges[y*w + x] = 1;
        }
    }
    edges = dilate(edges);
    let invertedEdges = new Uint8Array(w * h);
    for(let i=0; i<w*h; i++) invertedEdges[i] = edges[i] === 1 ? 0 : 1;

    // 5. Fast Connected Component Labeling
    function getComponents(binaryMap, minPrune, maxPrune) {
        let labels = new Int32Array(w * h);
        for(let i=0; i<w*h; i++) labels[i] = i;

        function findRoot(i) {
            let root = i;
            while(root !== labels[root]) root = labels[root];
            let curr = i;
            while(curr !== root) { let nxt = labels[curr]; labels[curr] = root; curr = nxt; }
            return root;
        }

        for (let y = 0; y < h; y++) {
            for (let x = 0; x < w; x++) {
                let idx = y * w + x;
                if (binaryMap[idx] === 1) {
                    let hasTop = y > 0 && binaryMap[idx - w] === 1;
                    let hasLeft = x > 0 && binaryMap[idx - 1] === 1;
                    if (hasTop && hasLeft) {
                        let rootTop = findRoot(idx - w);
                        let rootLeft = findRoot(idx - 1);
                        if(rootTop !== rootLeft) labels[rootTop] = rootLeft;
                        labels[idx] = rootLeft;
                    } else if (hasTop) { labels[idx] = findRoot(idx - w); } 
                      else if (hasLeft) { labels[idx] = findRoot(idx - 1); }
                }
            }
        }
        
        let components = new Map();
        for (let y = 0; y < h; y++) {
            for (let x = 0; x < w; x++) {
                let idx = y * w + x;
                if (binaryMap[idx] === 1) {
                    let root = findRoot(idx);
                    let c = components.get(root);
                    if (!c) {
                        components.set(root, { minX: x, maxX: x, minY: y, maxY: y, count: 1 });
                    } else {
                        if (x < c.minX) c.minX = x; if (x > c.maxX) c.maxX = x;
                        if (y < c.minY) c.minY = y; if (y > c.maxY) c.maxY = y;
                        c.count++;
                    }
                }
            }
        }
        
        let valid = [];
        for (let c of components.values()) {
            if (c.count >= minPrune && c.count <= maxPrune) valid.push(c);
        }
        return valid;
    }

    let minArea = (w * h) * (Number(minSizePercent) / 100);
    let maxArea = (w * h) * (Number(maxSizePercent) / 100);

    let compEdges = getComponents(invertedEdges, minArea, maxArea);
    let compBright = getComponents(binaryBright, minArea, maxArea);
    let compDark = getComponents(binaryDark, minArea, maxArea);
    let allDetections = [...compEdges, ...compBright, ...compDark];

    // 6. Circular Form Filtering & NMS Deduplication
    let sandDollars = [];
    let minAspect = 1.0 - Number(aspectTolerance);
    let maxAspect = 1.0 + Number(aspectTolerance);

    for (let c of allDetections) {
        // Discard items glued perfectly to the physical image wall (likely background regions)
        if (c.minX <= 2 || c.minY <= 2 || c.maxX >= w - 3 || c.maxY >= h - 3) continue;
        
        let wBox = c.maxX - c.minX + 1;  let hBox = c.maxY - c.minY + 1;
        let aspect = wBox / hBox;
        let fillRatio = c.count / (wBox * hBox); // perfect circle = 0.785
        
        if (aspect >= minAspect && aspect <= maxAspect && fillRatio >= 0.4 && fillRatio <= 0.95) {
            c.width = wBox;  c.height = hBox;
            c.cx = c.minX + wBox/2; c.cy = c.minY + hBox/2;
            c.score = Math.abs(1 - aspect) + Math.abs(0.785 - fillRatio);
            sandDollars.push(c);
        }
    }

    sandDollars.sort((a, b) => a.score - b.score);
    let finalDetections = [];
    for (let current of sandDollars) {
        let overlap = false;
        for (let kept of finalDetections) {
            let dx = current.cx - kept.cx; let dy = current.cy - kept.cy;
            let dist = Math.sqrt(dx*dx + dy*dy);
            let radiusProtection = Math.min(current.width, kept.width) / 2;
            if (dist < radiusProtection) { overlap = true; break; }
        }
        if (!overlap) finalDetections.push(current);
    }

    // 7. Render Bounding Circles and Labels
    ctx.lineWidth = 4;
    for(let i=0; i < finalDetections.length; i++) {
        let c = finalDetections[i];
        let r = Math.max(c.width, c.height) / 2;
        
        ctx.beginPath();
        ctx.arc(c.cx, c.cy, r, 0, Math.PI * 2);
        ctx.strokeStyle = '#00FF00'; ctx.lineWidth = 4; ctx.stroke();
        ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 2; ctx.stroke();
        
        ctx.fillStyle = '#FF4500';
        ctx.beginPath();
        ctx.arc(c.cx - r*0.7, c.cy - r*0.7, 16, 0, Math.PI*2);
        ctx.fill(); ctx.stroke();
        
        ctx.fillStyle = '#FFFFFF';
        ctx.font = 'bold 16px sans-serif';
        ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
        ctx.fillText((i+1).toString(), c.cx - r*0.7, c.cy - r*0.7);
    }
    
    // 8. Fun Meme Overlay Text
    let count = finalDetections.length;
    let message = count > 0 
        ? `Aw, barnacles! I counted ${count} sand dollar${count === 1 ? '' : 's'}!`
        : "Aw, barnacles, I'm outta sand dollars already...";
        
    let fontSize = Math.max(20, Math.floor(w / 25));
    ctx.font = `bold ${fontSize}px 'Comic Sans MS', sans-serif`;
    ctx.fillStyle = "yellow";
    ctx.strokeStyle = "black";
    ctx.lineWidth = 4;
    ctx.textAlign = "center";
    ctx.textBaseline = "top";
    
    ctx.strokeText(message, w/2, 20);
    ctx.fillText(message, w/2, 20);
    
    return canvas;
}

Free Image Tool Creator

Can't find the image tool you're looking for?
Create one based on your own needs now!

Description

The Image Sand Dollar Counter is a specialized image processing tool designed to detect and count circular objects in a photograph. Using advanced computer vision techniques—including grayscale conversion, morphological operations, and edge detection—the tool identifies items that match specific size, aspect ratio, and circularity profiles. It provides visual confirmation by drawing bounding circles and numbered labels around each detected object. This tool can be used for biological field research, marine life monitoring, or automating the counting of similar circular objects in various scientific and hobbyist contexts.

Leave a Reply

Your email address will not be published. Required fields are marked *