Contact Angle via Sessile Drop

Quick Start

Measuring contact angle is easy with a fancy device. But if you've just got a smartphone, some lighting and can place a drop carefully onto the level surface, you can load the captured image into the app to get a good-enough value via some simple fitting.

Credits

The Young-Laplace algorithm used to fit the drop shape is the one used in the Young-Laplace app.

Contact Angle from Sessile Drop

Fit h mm
Fit: b mm
Drop density ρ g/cc
Liquid σ mN/m
Rotation °
Threshold
Image Width mm
Smoothing
Base offset mm
X-Offset mm
CA θ
Vol μl
W mm
 Original image
 Magnify for h & b
±X mm
±Y mm
'use strict'
//One universal basic required here to get things going once loaded
let image, edge, edgemin, edgemax, imgWidth, imgHeight, context, imageData, oldImageData, oldAngle = 0, Smoothing=1, oldSmoothing=0

window.onload = function () {
    //restoreDefaultValues(); //Un-comment this if you want to start with defaults
    document.getElementById('Load').addEventListener('click', clearOldName, false);
    document.getElementById('Load').addEventListener('change', handleFileSelect, false);

    loadAndDrawImage("img/10ul H2O on PTFE Exemplar.png")
    //loadAndDrawImage("img/Test.jpg")
    //Main();
};
//Any global variables go here


//Main is hard wired as THE place to start calculating when input changes
//It does no calculations itself, it merely sets them up, sends off variables, gets results and, if necessary, plots them.
function Main() {
    saveSettings();

    //Send all the inputs as a structured object
    //If you need to convert to, say, SI units, do it here!
    const inputs = {
        h: sliders.Slideh.value / 1e3,
        b: sliders.Slideb.value / 1e3,
        delta: sliders.Slidedelta.value * 1e3,
        sigma: sliders.Slidesigma.value / 1e3,
        mm: sliders.Slidemm.value,
        Base: sliders.SlideBase.value,
        Xoffs: sliders.SlideXoffs.value,
        Xrange: sliders.SlideXrange.value,
        Yrange: sliders.SlideYrange.value,
       Angle: sliders.SlideAngle.value * Math.PI / 180, //Deg to Rad
    }
        Smoothing= sliders.SlideSmooth.value

    //Send inputs off to CalcIt where the names are instantly available
    //Get all the resonses as an object, result
    const result = CalcIt(inputs)

    //Set all the text box outputs
    document.getElementById('Volume').value = result.Volume
    document.getElementById('Width').value = result.Width
    document.getElementById('CA').value = result.CA

    //Do all relevant plots by calling plotIt - if there's no plot, nothing happens
    //plotIt is part of the app infrastructure in app.new.js
    if (result.plots) {
        for (let i = 0; i < result.plots.length; i++) {
            plotIt(result.plots[i], result.canvas[i]);
        }
    }

    //You might have some other stuff to do here, but for most apps that's it for CalcIt!
}

//Here's the app calculation
//The inputs are just the names provided - their order in the curly brackets is unimportant!
//By convention the input values are provided with the correct units within Main
function CalcIt({ h, b, delta, sigma, mm, Base, Xoffs, Xrange, Yrange, Angle }) {
    if (oldImageData != null) DoStuff(Base, mm, Angle)
    let tmp = [], DPlot = [], EPlot = [], z = h, x = 0, phi = 0, Volume = 0, R = 2 * b, dz = 0
    const phiinc = 0.001, pi = Math.PI, g = 9.81, dgs = delta * g / sigma
    let maxWidth = 0, cWidth = 0
    for (phi = 0; phi < pi; phi += phiinc) {
        tmp.push({ x: x * 1e3, y: z * 1e3 })
        x += R * Math.cos(phi) * phiinc
        maxWidth = Math.max(x, maxWidth)
        dz = R * Math.sin(phi) * phiinc
        z -= dz
        R = 1 / (dgs * (h - z) + 2 / b - Math.sin(phi) / x)
        Volume += pi * dz * x * x
        if (z <= 0) break
        cWidth = x //This will contain the last width before z=0
    }
    const CA = phi * 180 / Math.PI
    const tl = tmp.length - 1
    for (let i = 0; i <= tl; i++) {
        DPlot.push({ x: -tmp[tl - i].x, y: tmp[tl - i].y })
    }
    for (let i = 0; i < tmp.length; i++) {
        DPlot.push({ x: tmp[i].x, y: tmp[i].y })
    }
    //Now plot the edge, suitable scales/centered
    //console.log(edge)
    if (edge) {
        const mmW = mm / imgWidth
        const correction = mm / 2 + (edgemin * mmW - edgemax * mmW) / 2
        for (let i = 0; i < edge.length; i++) {
            EPlot.push({ x: edge[i].x * mmW - correction + Xoffs, y: Math.max(0, -Base + (imgHeight - edge[i].y) * mmW) })
        }
    }
    //Now set up all the graphing data detail by detail.
    let plotData = [DPlot, EPlot]
    let lineLabels = ["Calculated", "Edge"] //An array of labels for each dataset
    let xMM = [], yMM = []
    if (document.getElementById("Magnify").checked) {
        xMM.push(-Xrange); xMM.push(Xrange)
        yMM.push(h * 1e3); yMM.push(h * 1e3 - Yrange)
    } else {
        // xMM.push(-Math.ceil(2*maxWidth * 1000)/2);xMM.push(Math.ceil(2*maxWidth * 1000)/2)
        // yMM.push();yMM.push()
    }
    const prmap = {
        plotData: plotData,
        lineLabels: lineLabels,
        hideLegend: true,
        xLabel: "x& mm", //Label for the x axis, with an & to separate the units
        yLabel: "z& mm", //Label for the y axis, with an & to separate the units
        y2Label: undefined, //Label for the y2 axis, null if not needed
        yAxisL1R2: [], //Array to say which axis each dataset goes on. Blank=Left=1
        logX: false, //Is the x-axis in log form?
        xTicks: undefined, //We can define a tick function if we're being fancy
        logY: false, //Is the y-axis in log form?
        yTicks: undefined, //We can define a tick function if we're being fancy
        legendPosition: 'top', //Where we want the legend - top, bottom, left, right
        xMinMax: xMM, //Set min and max, e.g. [-10,100], leave one or both blank for auto
        yMinMax: yMM, //Set min and max, e.g. [-10,100], leave one or both blank for auto
        y2MinMax: [,], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        xSigFigs: 'F2', //These are the sig figs for the Tooltip readout. A wide choice!
        ySigFigs: 'F1', //F for Fixed, P for Precision, E for exponential
    };

    //Now we return everything - text boxes, plot and the name of the canvas, which is 'canvas' for a single plot

    return {
        Volume: (Volume * 1e9).toFixed(1),
        Width: (2 * cWidth * 1000).toFixed(1), // +" : " + (2*maxWidth * 1000).toFixed(1),
        CA: CA.toFixed(1),
        plots: [prmap],
        canvas: ['canvas'],
    };

}

function loadAndDrawImage(url) {
    // Create an image object. This is not attached to the DOM and is not part of the page.
    oldImageData = null
    image = new Image();

    // When the image has loaded, draw it to the canvas
    image.onload = function () {
        DoStuff(0, 0, 0)
        Main()
    }
    // Now set the source of the image that we want to load
    image.src = url;
}
function DoStuff(Base, mm, Angle) {

    canvas1.width = image.width;
    canvas1.height = image.height;
    document.getElementById("canvas1").style.cursor = "crosshair"

    context = canvas1.getContext("2d", { willReadFrequently: true });
    context.translate(canvas1.width / 2, canvas1.height / 2)
    context.rotate(Angle)
    context.translate(-canvas1.width / 2, -canvas1.height / 2)
    context.filter = 'blur('+Smoothing+'px)';
    context.drawImage(image, 0, 0);
    if (oldImageData == null || oldAngle != Angle|| oldSmoothing != Smoothing) {
        imageData = context.getImageData(0, 0, canvas1.width, canvas1.height);
        oldImageData = new Uint8ClampedArray(imageData.data)
    } else {
        imageData.data.set(oldImageData)
    }
    if (Base == 0 && mm == 0 && Angle == 0) return
    oldAngle = Angle;oldSmoothing=Smoothing
    const data = imageData.data;
    const xSize = imageData.width, ySize = imageData.height
    imgWidth = xSize; imgHeight = ySize
    const threshold = sliders.Slidethreshold.value * 3
    let x, y, pos
    for (y = 1; y < ySize - 1; y++) {
        for (x = 0; x < xSize - 0; x++) {
            pos = (y * xSize + x) * 4;
            if (data[pos] + data[pos + 1] + data[pos + 2] > threshold) {
                data[pos] = 255; data[pos + 1] = 255; data[pos + 2] = 255
            }
            else {
                data[pos] = 0; data[pos + 1] = 0; data[pos + 2] = 0
            }
        }
    }
    edge = [], edgemin = xSize; edgemax = 0;
    for (y = 1; y < ySize; y++) {
        for (x = 1; x < xSize; x++) {
            pos = (y * xSize + x) * 4;
            if (data[pos - 4] > data[pos]) {
                edge.push({ x: x, y: y + 1 })
                if (y < 0.8 * ySize && y < Base * imgWidth / mm) edgemin = Math.min(edgemin, x)
                for (let yy = Math.max(y-8,0); yy < Math.min(ySize-1,y + 8); yy++) {
                    for (let xx = Math.max(x-8,0); xx < Math.min(xSize-1,x + 8); xx++) {
                        pos = (yy * xSize + xx) * 4
                         data[pos + 1] = 255; data[pos + 2] = 255
                    }
                }
                break
            }
        }
    }
    for (y = ySize; y > 0; y--) {
        for (x = xSize - 1; x > 0; x--) {
            pos = (y * xSize + x) * 4;
            if (data[pos] > data[pos - 4]) {
                edge.push({ x: x, y: y + 1 })
                if (y < 0.8 * ySize && y < Base * imgWidth / mm) edgemax = Math.max(edgemax, x)
                for (let yy = Math.max(y-8,0); yy < Math.min(ySize-1,y + 8); yy++) {
                    for (let xx = Math.max(x-8,0); xx < Math.min(xSize-1,x + 8); xx++) {
                        pos = (yy * xSize + xx) * 4
                         data[pos + 1] = 255; data[pos + 2] = 0
                    }
                }
                break
            }
        }
    }

    edgemax = xSize - edgemax
    if (!document.getElementById("Original").checked) context.putImageData(imageData, 0, 0)

}
function clearOldName() { //This is needed because otherwise you can't re-load the same file name!
    document.getElementById('Load').value = ""
}
function handleFileSelect(evt) {
    var f = evt.target.files[0];
    if (f) {
        if (f.type !== '' && !f.type.match('image.*')) {
            return;
        }
        // The URL API is vendor prefixed in Chrome
        window.URL = window.URL || window.webkitURL;

        // Create a data URL from the image file
        var imageURL = window.URL.createObjectURL(f);

        loadAndDrawImage(imageURL);
    }
}
        

The 4 problems

There are 4 problems for measuring contact angle:

  1. Placing a controlled drop of fluid onto the surface which is perfectly level.
  2. Capturing a well-lit, high-contrast image of the drop and knowing the width, in mm, of the whole image.
  3. Getting rid of noise in the image to make fitting easier.
  4. Fitting the drop shape in order to extract the contact angle.

A wonderful 3D-printed device is under development for tasks 1 and 2 and all relevant parts/files will be provided once we've finished the final debugging. The trick is to use a modern, high quality, low cost ($50) digital microscope. But for now, let's assume you have a good-enough image.

You need to input three pieces of real data:

  • ρ: The density of your probe liquid. As this is often water, the default value is 1
  • σ:The surface tension of your liquid - defaulting to 72 mN/m for water
  • Image Width: The width of the full image, which defines the scale used to compare calculated to real drop shape

Now there are 6 fitting parameters (the Threshold value is discussed below). The first 2 are all about the drop, the others are about aligning/adjusting the image to allow you to get reliable values for the first 2. Perfectly-lit drops on perfect substrates shouldn't need extra adjustments, but images of drops on real substrates with real lighting need plenty of help from the adjustments:

  • h: The height of the drop from interface to heighest point.
  • b: This is the radius of curvature of the drop at its highest point.
  • Base offset: This allows you to define where in the image the drop starts. It's surprisingly hard to pinpoint the drop/substrate interface.
  • X-offset: This allows you to shift the fitted image left-right to fix symmetry issues.
  • Rotation: This allows you to fix problems with (slightly) tilted images.
  • Smoothing: Imaged edge detection can be fooled by noise. Adding a small amount of smoothing can make the image much easier to fit.

Young-Laplace fitting algorithms are notoriously tricky because it is an "ill-posed problem". Images have artefacts that can confuse algorithms. Humans are smarter than algorithms so although h and b could be calculated via software from the image, it's preferable to make them a tunable parameter, so the final θ, and its variability, are judged by the human brain, not an algorithm.

The Rotation option allows you to tweak small angle errors from your camera image. The Base and X offsets give you freedom to cope with tricky images. And Smoothing often helps - but don't lose too much detail at the interface by over-smoothing. Via the Magnify option, and tuning the X and Y range for best viewing, you can adjust the h and b values to get a good match for both parameters. In practice, Base offset and h are trivial to get right so b is all you need to vary to optimise the fit, allowing the contact angle θ to be calculated.

Outputs

In addition to θ you get the drop volume and width. In some methods, the volume is an input value, constraining the fitting. If you know your drop volume accurately then you can use this calculated value as a check on the accuracy of the method.

Young-LaplaceSessile Drop

There are 3 interconnected equations governing the shape. They depend on b, h, σ, ρ and on g, gravity. Note that the contact angle, θ, doesn't appear as an input - discussed below. They relate to the angle φ which is 0 at the top of the drop and increases as you go downwards. The x-axis is horizontal and z is vertical. The radius of curvature at each point is R, with R=b at the lowest point. We then have:

  • `(δx)/(δφ)=Rcosφ`
  • `(δz)/(δφ)=Rsinφ`
  • `1/R=(Δρg)/σ (h-z)+2/b-(sinφ)/x`

There is no analytical solution to this, so you just start at the bottom and step through small increments in φ. The numerics can explode when your settings don't match potential realities, so don't be alarmed by strange results.

The contact angle is simply φ when z = 0. Why are we deriving it from a complex equation rather than simply measuring it via a stanard method - such as fitting a spherical cap shape? The answer is that for very small drops, where the mass of the drop does not affect the shape, the spherical cap fit is fine. But larger (more practical) drops start to deform, needing the Y-L fitting. This means that the contact angle isn't the "true" angle. Whether it's possible to go from the Y-L fit to a "no gravity" value is something I don't yet know.

Thresholding, Lighting, Image Width

The technique for going from image to outline for you to fit is rudimentary - it depends on your threshold value. With a good image, even large changes to the threshold value will not alter the drop shape significantly. But with real-world images you may have to play around to get the best balance. The trick is to choose a threshold, measure θ, then change the threshold and re-measure θ. If it changes a lot then you probably need a better image. If the change is small, then you can be confident in the result.

There are sophisticated image analysis techniques which might make shape extraction more accurate in the case of poorer-quality images. But experience shows that there's no substitute for a good original image. So pour your energies into a setup that is at the same time simple and reliable.

Lighting is key. Modern "YouTuber" USB-chargeable diffuse LED lights are low cost and excellent. Artefacts from poor lighting get in the way of reliable thresholding. Anything in the path of the light will interfere with the contrast, as will up/down tilt in the platform. Reflections from the drop also make it harder to define the drop edges.

Defining the width is surprisingly simple. Cut out a piece of a ruler, say 5mm x 30mm, containing mm scale markings. Stick something to the back so you can place it on the platform at the same distance from the microscope lens as the droplet. You can easily read out the width to sufficient accuracy. If you have a fancy graticule with finer gradations, that's better, but the simple piece of ruler works well.

How accurate are the results?

You can answer this question yourself. Although there are many fancy techniques claiming to provide high accuracy, you can't do better than the quality of the setup (e.g. how level it is) and of the image (contrast, reflections, sharpness, focus, ...). By changing the relevant parameters over a modest range, you can see how much or little your calculated angle changes, and choose the best compromise as your final value.

Then you need to ask what accuracy you need. What outcomes depends on that angle? If ±1° makes little difference to the outcome, you're fine. If you worry about ±0.1° then you should use professional equipment.