Patapon
Patapon

Reputation: 31

What is the fastest way to display large datasets as images in .NET MAUI?

I am building a cross-platform application using .NET MAUI that needs to display large sets of data. The data comes in as a 2D float[,] array, each point gets attributed a Color and should then be displayed on the screen. Here is what I am after: Example of how the data should be displayed

Because the data set can be extremely large (2000 rows x 1000000 columns for example), if I try to simply load it entirely as an Image or GraphicView, it takes a very long time.

So what I want to do is draw the data one column at the time. How can I achieve this?

My most successful attempt so far has been to use an IDispatcherTimer timer in the C# code behind the view:

protected override void OnAppearing()
{
    base.OnAppearing();

    string pathToTheDataset = Path.Combine(appFolderPath, "large_dataset");

    IDispatcherTimer timer;
    timer = Dispatcher.CreateTimer();
    timer.Interval = TimeSpan.FromMilliseconds(100);

    int columnNumber = 0;
    timer.Tick += (s, e) =>
    {
        AppendImage(pathToTheDataset, columnNumber, numberOfRows, numberOfColumns);
        Color[] column = ExtractOneColumn(pathToTheDataset, columnNumber);

        SkiaBitmapExportContext bitmap = new SkiaBitmapExportContext(width, height, 1.0f);
        ICanvas canvas = bitmap.Canvas;
        canvas.StrokeSize = 1;

        for (int i = 0; i < column.Length; i++)
        {
            canvas.StrokeColor = column[i]
            canvas.DrawRectangle(columnNumber, i, 1, 1)
        }

        var skImage = bitmap.SKImage;
        SKData encodedData = skImage.Encode(SKEncodedImageFormat.Png, 100)

        string imagePath = Path.Combine(AppFolder, "data.png");

        var bitmapImageStream = File.Open(imagePath, FileMode.Create, FileAccess.Write, FileShare.None);

        encodedData.SaveTo(bitmapImageStream);
        bitmapImageStream.Flush(true);
        bitmapImageStream.Dispose();

        dataImage.Source = ImageSource.FromFile(imagePath);

        columnNumber++;
        if (columnNumber == numberOfColumn - 1)
        {
            timer.Stop();
        }
    };

    timer.Start();
}

This code was inspired by the answer to this post.

The timer allows me to update the ImageSource of the Image (x:Name = "dataImage") in my View but I do not understand how to add the next column to the previous one in that Image.

I have tried to break this code into several methods so that it only updates the SkiaBitmapExportContext object but then the Image remains stuck at the first column although the timer increments as it should with no error/exception.

Any suggestion is welcomed, thank you.

Upvotes: 1

Views: 738

Answers (1)

Patapon
Patapon

Reputation: 31

I found a solution to my problem which worked on both Windows and Android (I did not test iOS and mac). I tested it on a dataset that had 512 rows x 55856 columns and I could draw the image in < 2 seconds (from reading the file containing the dataset all the way to showing the Image on screen).

Admittedly the dataset is smaller than what I specified in my question but it is the biggest dataset I have at my disposal right now. Also < 2 seconds is the time it took to dump everything at once in the Image and it is possible to improve user experience by progressively updating the image instead.

Here is the code:

  1. Write pixel in a byte array.
private async Task WritePixelsAsync()
{
    pixelArray = new byte[numberOfColumns * numberOfRows * 4]; 
   // The 4 is because ARGB is 32 bits per pixel and 1 byte = 8 bits.

    int loopIndex = 0;
    await Task.Run(() =>
    {
        for (int i = 0; i < numberOfColumns * 4; i += 4)
        {
            for (int j = 0; j < numberOfRows; j++)
            {
                // Create a color scale, in my case I want a grey scale.
                int greyScale = (int)MathF.Round(data[loopIndex, j] / 256 + 128);
                greyScale = Math.Clamp(greyScale, 0, 255);

                //BGRA order because it is a bitmap.
                pixelArray[i + (numberOfColumns * 4 * j)] = (byte)greyScale;
                pixelArray[i + 1 + (numberOfColumns  * 4 * j)] = (byte)greyScale;
                pixelArray[i + 2 + (numberOfColumns * 4 * j)] = (byte)greyScale;
                pixelArray[i + 3 + (numberOfColumns * 4 * j)] = 255;
            }
            loopIndex++;
        }
    });
}
  1. Create a bitmap manually and put it in a MemoryStream.
private static MemoryStream GenerateBitmapStreamFromPixelArray(int numberOfColumns, int numberOfRows, byte[] pixelArray)
{
    int bitsPerPixel = 32; // 32 bits per pixel (ARGB)
    int rowSize = numberOfColumns * (bitsPerPixel / 8);
    int imageSize = rowSize * numberOfRows;

    byte[] headerBytes = new byte[54]; // Header size is 54 bytes for bitmap format.
    
    // Bitmap file header (14 bytes)
    headerBytes[0] = 0x42; // Signature ('B')
    headerBytes[1] = 0x4D; // Signature ('M')
    BitConverter.GetBytes(14 + 40 + imageSize).CopyTo(headerBytes, 2); // File size
    BitConverter.GetBytes(54).CopyTo(headerBytes, 10); // Offset to image data

    // Bitmap info header (40 bytes)
    BitConverter.GetBytes(40).CopyTo(headerBytes, 14); // Info header size
    BitConverter.GetBytes(numberOfColumns).CopyTo(headerBytes, 18); // Image width
    BitConverter.GetBytes(numberOfRows).CopyTo(headerBytes, 22); // Image height
    headerBytes[26] = 1; // Number of color planes
    headerBytes[28] = (byte)bitsPerPixel; // Bits per pixel
    BitConverter.GetBytes(imageSize).CopyTo(headerBytes, 34); // Image size
    BitConverter.GetBytes(resolution).CopyTo(headerBytes, 38); // Horizontal resolution (pixels per meter)
    BitConverter.GetBytes(resolution).CopyTo(headerBytes, 42); // Vertical resolution (pixels per meter)

    // Reverse the order of rows in pixelArray because bitmap format wants data as "little endian" and not "big endian".
    byte[] reversedPixelArray = new byte[pixelArray.Length];

    for (int i = 0; i < numberOfRows; i++)
    {
        int sourceIndex = (numberOfRows - 1 - i) * rowSize;
        int targetIndex = i * rowSize;
        Array.Copy(pixelArray, sourceIndex, reversedPixelArray, targetIndex, rowSize);
    }

    // Append reversed pixel data to the header
    byte[] bmpData = new byte[headerBytes.Length + reversedPixelData.Length];
    headerBytes.CopyTo(bmpData, 0);
    reversedPixelData.CopyTo(bmpData, headerBytes.Length);

    MemoryStream stream = new(bmpData);
    return stream;
}

I used this Wikipedia page to learn how to properly write the bitmap format.

  1. Update your Image's ImageSource from the MemoryStream.
ImageSource.FromStream(() => GenerateBitmapStreamFromPixelArray(numberOfColumns, numberOfRows, pixelArray));

Note that there is another solution that I found using a combination of SKBitmap, SkiaImage and MemoryStream; it is a bit slower but requires much less code, if your datasets are not as large as mines maybe it is worth investigating.

Upvotes: 2

Related Questions