Skip to main content

available March 2025

This portfolio uses the latest CSS features, like scroll driven animations and view transitions, your browser does not support (all of) them. I've added fallback animations, but for the best experience, please consider using a modern chromium-based browser.

March 11, 2025

Written by: Cyd Stumpel

Finding fish in a sea of grey and green

At the start of this month one of my favourite websites went live (it has existed for a while, but I implemented the rebrand); the fish doorbell (visdeurbel.nl). The concept is really simple; a website showing a livestream at a lock in Utrecht, where users are asked to press the doorbell if they see a fish, creating a snapshot that’s uploaded to the backend.

Due to the enormous amount of visitors (almost twice as many on the first day compared to last year) we got 40k images in two days. To make things worse… In the first week the water in the river was still very cold and there were no fish visible on the livestream. I went through 40k grey- and green mostly completely empty images by hand, to delete the empty ones and give the servers some rest.

Day 1 of the Fish Doorbell, not much to see (cloudy day, so grey water)
Day 2 of the Fish Doorbell, still not much to see (sunny day, so green water)
Day 2…
Me slightly panicking on day 2, sending a message to my client; “The screenshots are really escalating”

We get users from all over the world and, while our biggest user group is still from The Netherlands, it’s also very popular in the United States and UK. We’ve been getting emails from retired people touring through Australia keeping up with our little lock in Utrecht.

The Fish Doorbell has gained massive popularity over the last couple of years. At the very start, 5 years ago, an actual email was sent to the lock keeper every time someone pushed the doorbell. That would be absolute madness now, as it would result in 40000 emails.

Something had to change. I was not prepared for this amount of success, partly because it’s my first year managing this website, but also because we didn’t expect to get even more users than last year.

Several Dutch news sources started reporting on visdeurbel.nl not being able to handle all the users, and my heart jumped, thinking I missed massive downtimes. Luckily they were just talking about the livestream not being able to handle the amount of users. The livestream is capped on 2000 users at a time to also help limit the amount of users. There’s a youtube livestream visible when the maximum is reached and users are not able to ring the bell anymore. Unfortunately this still resulted in way too many empty images being submitted.

Link to:

Creating a script to recognise shapes

Pressing a button is fun, we get that! And most users can’t have been aware of the strain it put on our server. The Fish Doorbell users are super loyal and well meaning, so I decided; we need to find a way to calculate the probability that there’s something visible in an image and show users a warning to not submit empty images to spare our servers.

I started searching Google for ways to detect if an image is empty, this turned up some results but they mostly required expensive Artificial Intelligence APIs. I came across a library that hasn’t been updated in 2 years called Resemble.js. This seemed like the perfect solution; it allows you to compare two images and it will return the difference.

This worked great during the day, I’d compare an image to a screenshot of the empty lock, and, if it was too similar I could be sure that the image was mostly empty and show our little warning message:

Using the term algorithm very loosely here😅

Unfortunately, during the night, the light and shadows in the lock changed so much that an empty image during the day and an empty image at night were registered as completely different images.

Fish Doorbell before sunrise
Fish Doorbell after sunrise

I asked around in my network of creative developers and came to the conclusion I needed to do something called edge detection. I gave this resource to Claude in my Cursor code editor, and asked it to turn it into JavaScript. I’m sure it can be optimised greatly, but it works quite well for my use case:

/**
 * Edge Detection Module
 * Inspired by: https://tonio73.github.io/data-science/cnn/CnnEdgeDetection-Keras-Part1.html
 */

const EdgeDetection = {
  /**
   * Apply Sobel operator for edge detection on a base64 image
   * @param {string} base64Image - Base64 encoded image
   * @param {string} mode - 'horizontal', 'vertical', or 'combined'
   * @returns {Promise<Object>} - Object containing processed image and object detection probability
   */
  detectEdges: function(base64Image, mode = 'combined') {
    return new Promise((resolve, reject) => {
      const img = new Image()
      img.onload = () => {
        try {
          // Create canvas and get image data
          const canvas = document.createElement('canvas')
          const ctx = canvas.getContext('2d')
          canvas.width = img.width
          canvas.height = img.height
          ctx.drawImage(img, 0, 0)
          
          const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
          const result = this.applySobelFilter(imageData, mode)
          
          // Put processed data back to canvas
          ctx.putImageData(result.processedData, 0, 0)
          
          resolve({
            processedImage: canvas.toDataURL(),
            objectProbability: result.objectProbability
          })
        } catch (error) {
          reject(error)
        }
      }
      img.onerror = (error) => reject(error)
      img.src = base64Image
    })
  },
  
  /**
   * Apply Sobel filter to image data and calculate object probability
   * @param {ImageData} imageData - Original image data
   * @param {string} mode - 'horizontal', 'vertical', or 'combined'
   * @returns {Object} - Object containing processed image data and object probability
   */
  applySobelFilter: function(imageData, mode) {
    // Sobel operator kernels
    const kernelX = [
      [-1, 0, 1],
      [-2, 0, 2],
      [-1, 0, 1]
    ]
    
    const kernelY = [
      [-1, -2, -1],
      [0, 0, 0],
      [1, 2, 1]
    ]
    
    const width = imageData.width
    const height = imageData.height
    const data = imageData.data
    
    // Create output image data
    const outputData = new ImageData(width, height)
    const output = outputData.data
    
    // Convert to grayscale first
    const grayscale = new Uint8ClampedArray(width * height)
    for (let i = 0; i < height; i++) {
      for (let j = 0; j < width; j++) {
        const idx = (i * width + j) * 4
        // Standard grayscale conversion
        grayscale[i * width + j] = Math.round(
          0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2]
        )
      }
    }
    
    // Variables to track edge information
    let totalEdgeStrength = 0
    let maxPossibleEdgeStrength = 0
    let significantEdgeCount = 0
    const edgeThreshold = 30 // Threshold to consider an edge significant
    
    // Apply convolution
    for (let y = 1; y < height - 1; y++) {
      for (let x = 1; x < width - 1; x++) {
        let pixelX = 0
        let pixelY = 0
        
        // Apply kernels
        for (let ky = -1; ky <= 1; ky++) {
          for (let kx = -1; kx <= 1; kx++) {
            const idx = (y + ky) * width + (x + kx)
            const kernelValue = grayscale[idx]
            
            pixelX += kernelValue * kernelX[ky + 1][kx + 1]
            pixelY += kernelValue * kernelY[ky + 1][kx + 1]
          }
        }
        
        // Calculate magnitude based on mode
        let magnitude
        if (mode === 'horizontal') {
          magnitude = Math.abs(pixelX)
        } else if (mode === 'vertical') {
          magnitude = Math.abs(pixelY)
        } else { // combined
          magnitude = Math.sqrt(pixelX * pixelX + pixelY * pixelY)
        }
        
        // Track edge information for object detection
        totalEdgeStrength += magnitude
        maxPossibleEdgeStrength += 255 // Maximum possible value
        if (magnitude > edgeThreshold) {
          significantEdgeCount++
        }
        
        // Clamp values
        magnitude = Math.min(255, Math.max(0, magnitude))
        
        // Set pixel in output
        const outIdx = (y * width + x) * 4
        output[outIdx] = magnitude     // R
        output[outIdx + 1] = magnitude // G
        output[outIdx + 2] = magnitude // B
        output[outIdx + 3] = 255       // Alpha
      }
    }
    
    // Calculate object probability based on edge information
    const totalPixels = (width - 2) * (height - 2) // Exclude border pixels
    
    // Multiple factors contribute to object probability:
    // 1. Edge density (ratio of significant edges to total pixels)
    const edgeDensity = significantEdgeCount / totalPixels
    
    // 2. Average edge strength relative to maximum possible
    const avgEdgeStrength = totalEdgeStrength / maxPossibleEdgeStrength
    
    // 3. Edge distribution - check if edges form patterns rather than noise
    // This is a simplified approach - real object detection would use more sophisticated methods
    const edgeDistributionFactor = this.calculateEdgeDistribution(outputData)
    
    // Combine factors with appropriate weights
    const objectProbability = Math.min(1.0, Math.max(0.0,
      0.4 * edgeDensity + 
      0.3 * avgEdgeStrength + 
      0.3 * edgeDistributionFactor
    ))
    
    return {
      processedData: outputData,
      objectProbability: objectProbability
    }
  },
  
  /**
   * Calculate edge distribution factor
   * Checks if edges form patterns rather than random noise
   * @param {ImageData} imageData - Processed image with edges
   * @returns {number} - Edge distribution factor (0-1)
   */
  calculateEdgeDistribution: function(imageData) {
    const width = imageData.width
    const height = imageData.height
    const data = imageData.data
    
    // Count connected edge pixels
    let connectedEdgeCount = 0
    const edgeThreshold = 30
    
    for (let y = 1; y < height - 1; y++) {
      for (let x = 1; x < width - 1; x++) {
        const idx = (y * width + x) * 4
        const pixelValue = data[idx] // Just check red channel since R=G=B
        
        if (pixelValue > edgeThreshold) {
          // Check if any neighboring pixel is also an edge
          let hasNeighbor = false
          
          // Check 8 neighbors
          for (let ny = -1; ny <= 1; ny++) {
            for (let nx = -1; nx <= 1; nx++) {
              if (nx === 0 && ny === 0) continue // Skip self
              
              const nIdx = ((y + ny) * width + (x + nx)) * 4
              if (data[nIdx] > edgeThreshold) {
                hasNeighbor = true
                break
              }
            }
            if (hasNeighbor) break
          }
          
          if (hasNeighbor) {
            connectedEdgeCount++
          }
        }
      }
    }
    
    // Calculate ratio of connected edges to all edge pixels
    const totalEdgePixels = this.countEdgePixels(imageData, edgeThreshold)
    if (totalEdgePixels === 0) return 0
    
    return connectedEdgeCount / totalEdgePixels
  },
  
  /**
   * Count total edge pixels in the image
   * @param {ImageData} imageData - Processed image with edges
   * @param {number} threshold - Threshold to consider a pixel as edge
   * @returns {number} - Count of edge pixels
   */
  countEdgePixels: function(imageData, threshold) {
    const width = imageData.width
    const height = imageData.height
    const data = imageData.data
    let count = 0
    
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        const idx = (y * width + x) * 4
        if (data[idx] > threshold) {
          count++
        }
      }
    }
    
    return count
  }
}

export default EdgeDetection
JavaScript

Two more issues I ran into;

  • Even if there were fish in the images, they blended into the background pretty well.
  • The date, time and KBTS text gave some false positives

I asked Claude to write a script to make the shadows darker, the contrast higher and the exposure higher. As well as blurring out the top right and bottom left corner to hide the date and KBTS as much as possible. This is all done on a <canvas> element.

Original image on the left. Image with effects and blur in the middle. Image that the edge detector bases its results on, on the right

Everyone trying to upload an image below 32% will be shown the warning, it also adds a tag (high, medium, low) to the image based on the probability to help the ecologist, Mark van Heukelum with sorting through the images.

Images with a high probability of having ‘something’ in it

We went from ±20k images a day to approximately 1000 image uploads a day💪, we’re still seeing a lot of leafs and it’s not as good at detecting shapes when the sun is down, but it has greatly helped the amount of images we need to go through!

Portrait of Cyd Stumpel, wearing a white t-shirt posing for the picture.

Cyd Stumpel

Cyd is a Freelance Creative Developer and teacher from Amsterdam. She teaches at the Amsterdam University of Applied Sciences and occastionally speaks at conferences and meetups.

Last updated: March 11, 2025