A Beer Color Meter for Windows in WinUI 3

In this article we present a WinUI 3 Desktop app that allows you to

  • pick whatever image – but preferably one with a glass of beer in it,
  • select an area inside it via an image cropper,
  • calculate the average color of the selected area’s pixels,
  • look up the nearest official beer color for that average color, and
  • visually validate the result with a slider.

The app is appropriately called Beer Color Meter and it looks like this:

Beer color is generally measured in Standard Reference Method (SRM) or European Brewery Convention (EBC) units. In a laboratory this would involve

  • measuring the attenuation of light with a wavelength of 430 nm when passing through 1 cm of the beer,
  • expressing the attenuation as an absorption, and
  • scaling the absorption by a constant, being 12.7 for SRM and 25 for EBC.

It turns out we don’t have such a laboratory at home, instead we built an app 😉.

The Model

An SRM value corresponds to a color, so it should not come as a surprise that our BeerColor class hosts a Color struct. It has properties to hold red (R), green (G), and blue (B) components. A BeerColorGroup gives a name to a range of colors – e.g. beers with a color of 5 or 6 SRM are called ‘Gold’.

Here’s a class diagram of the domain of the beer color meter app:

The Data Access Layer

Since all of its core data is fixed, the app’s Data Access Layer is just two static methods. One returns a list of Beer Colors:

var result = new List<BeerColor>
{
    new BeerColor() { SRM = 0.1, R = 248, G = 248, B = 230 },
    new BeerColor() { SRM = 0.2, R = 248, G = 248, B = 220 },
    new BeerColor() { SRM = 0.3, R = 247, G = 247, B = 199 },
    new BeerColor() { SRM = 0.4, R = 244, G = 249, B = 185 },
    new BeerColor() { SRM = 0.5, R = 247, G = 249, B = 180 },
    new BeerColor() { SRM = 0.6, R = 248, G = 249, B = 178 },
    new BeerColor() { SRM = 0.7, R = 244, G = 246, B = 169 },
    new BeerColor() { SRM = 0.8, R = 245, G = 247, B = 166 },
    // ...

The other returns a list of Beer Color Groups:

List<BeerColorGroup> result = new()
{
    new BeerColorGroup()
    {
        ColorName = "Straw",
        MinimumSRM = 0,
        MaximumSRM = 3
    },
    new BeerColorGroup()
    {
        ColorName = "Yellow",
        MinimumSRM = 3,
        MaximumSRM = 5
    },
    new BeerColorGroup()
    {
        ColorName = "Gold",
        MinimumSRM = 5,
        MaximumSRM = 6
    },
    // ...

The View

The View consists of an image, some buttons, and a grid for the result. They’re put against this fancy beer color themed LinearGradientBrush background:

The selected beer picture is decorated with an ImageCropper from Windows Community Toolkit. Here’s how that control looks like in the Toolkit Gallery app:

All its default settings and (nice!) animations suit out use case, so the XAML is as simple as can be:

<controls:ImageCropper x:Name="ImageCropper" />

The Logic

We let the user pick and open a file with a FileOpenPicker. The code for this comes straight out of the book – including that awkward interop window handle thing:

private async Task PickImage()
{
    // Create a file picker
    var openPicker = new FileOpenPicker();
    var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
    WinRT.Interop.InitializeWithWindow.Initialize(openPicker, hWnd);

    // Set options
    openPicker.ViewMode = PickerViewMode.Thumbnail;
    openPicker.SuggestedStartLocation = PickerLocationId.PicturesLibrary;
    openPicker.FileTypeFilter.Add(".bmp");
    openPicker.FileTypeFilter.Add(".jpg");
    openPicker.FileTypeFilter.Add(".jpeg");
    openPicker.FileTypeFilter.Add(".png");

    // Open the picker
    var file = await openPicker.PickSingleFileAsync();
    await OpenFile(file);
}

Get ready for the pièce de résistance: the algorithm to get the selected pixels for calculating their average color relies on rarely used (at least by us) classes and helpers. At the end of the day we need these pixels as a properly encoded byte array (e.g. in a red-green-blue-transparency sequence).

Here’s how it goes:

And in C#:

BitmapDecoder decoder;
BitmapTransform transform;

// Create a .png from the cropped image
var stream = new InMemoryRandomAccessStream();
await ImageCropper.SaveAsync(stream, BitmapFileFormat.Png);
stream.Seek(0);
decoder = await BitmapDecoder.CreateAsync(stream);
stream.Dispose();

transform = new()
{
    ScaledWidth = (uint)ImageCropper.CroppedRegion.Width,
    ScaledHeight = (uint)ImageCropper.CroppedRegion.Height
};

// Get the pixels
PixelDataProvider pixelData = await decoder.GetPixelDataAsync(
    BitmapPixelFormat.Rgba8,
    BitmapAlphaMode.Straight,
    transform,
    ExifOrientationMode.IgnoreExifOrientation,
    ColorManagementMode.DoNotColorManage
);

byte[] sourcePixels = pixelData.DetachPixelData();

For average pixel color we assume the color from the average red, the average green, and the average blue value:

// Calculate average color
var nbrOfPixels = sourcePixels.Length / 4;
int avgR = 0, avgG = 0, avgB = 0;
for (int i = 0; i < sourcePixels.Length; i += 4)
{
    avgR += sourcePixels[i];
    avgG += sourcePixels[i + 1];
    avgB += sourcePixels[i + 2];
}

var color = Color.FromArgb(255, (byte)(avgR / nbrOfPixels), (byte)(avgG / nbrOfPixels), (byte)(avgB / nbrOfPixels));
Result.Background = new SolidColorBrush(color);

To fetch the nearest beer color, we return from our beer color list the one with the shortest Euclidean distance to the average pixel color in the RGB-space:

// Calculate nearest beer color
double distance = int.MaxValue;
BeerColor closest = DAL.BeerColors[0];
foreach (var beerColor in DAL.BeerColors)
{
    double d = Math.Sqrt(Math.Pow(beerColor.B - color.B, 2)
                        + Math.Pow(beerColor.G - color.G, 2)
                        + Math.Pow(beerColor.R - color.R, 2));
    if (d < distance)
    {
        distance = d;
        closest = beerColor;
    }
}

DisplayResult(closest);

A slider over the beer color range allows the user to visually inspect the result:

private void BeerColorSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
{
    var closest = DAL.BeerColors.Where(c => c.SRM == e.NewValue).FirstOrDefault();
    if (closest != null)
    {
        DisplayResult(closest);
    }
}

And there we are: a nice fully functioning Beer Color Meter for Windows.

But why does it look like an app for the phone?

Building Beer Color Meter was a fun and challenging exercise, but there’s more to it. With this WinUI 3 app we wanted to create a reference app that is different from most of the getting started apps for phone development – think image gallery, shopping list, or video viewer. In the next couple of weeks we plan to transform Beer Color Meter into an Android app, using different cross platform .NET XAML environments, such as Uno Platform, Avalonia UI, and .NET MAUI. We’ll keep you posted on that…

In mean time, the WinUI 3 version of Beer Color Meter lives here on GitHub.

Enjoy!

Leave a comment