Reputation: 31
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
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:
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++;
}
});
}
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.
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