From cef4680a91a3187a3b2f836267deeda30cffc68f Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Sun, 5 Apr 2026 09:30:20 +0200 Subject: [PATCH 01/10] Optimize bitmap operations --- PhotoLocator/BitmapOperations/FloatBitmap.cs | 86 ++++++++++++++++--- .../BitmapOperations/FloatBitmapTest.cs | 69 ++++++++++++++- 2 files changed, 141 insertions(+), 14 deletions(-) diff --git a/PhotoLocator/BitmapOperations/FloatBitmap.cs b/PhotoLocator/BitmapOperations/FloatBitmap.cs index 152697e..5114259 100644 --- a/PhotoLocator/BitmapOperations/FloatBitmap.cs +++ b/PhotoLocator/BitmapOperations/FloatBitmap.cs @@ -136,11 +136,49 @@ public void Assign(BitmapSource source, double gamma) fixed (float* elements = &Elements[y, 0]) fixed (byte* sourceRow = &sourcePixels[y * Width * 4]) { - for (var x = 0; x < Width; x++) + var w = Width; + var srcIndex = 0; + var dstIndex = 0; + float* g = gamma; + float* e = elements; + byte* s = sourceRow; + int x = 0; + + // Process 4 pixels per iteration to reduce loop overhead + for (; x + 3 < w; x += 4) + { + // pixel 0 + e[dstIndex + 2] = g[s[srcIndex + 0]]; + e[dstIndex + 1] = g[s[srcIndex + 1]]; + e[dstIndex + 0] = g[s[srcIndex + 2]]; + + // pixel 1 + e[dstIndex + 5] = g[s[srcIndex + 4 + 0]]; + e[dstIndex + 4] = g[s[srcIndex + 4 + 1]]; + e[dstIndex + 3] = g[s[srcIndex + 4 + 2]]; + + // pixel 2 + e[dstIndex + 8] = g[s[srcIndex + 8 + 0]]; + e[dstIndex + 7] = g[s[srcIndex + 8 + 1]]; + e[dstIndex + 6] = g[s[srcIndex + 8 + 2]]; + + // pixel 3 + e[dstIndex + 11] = g[s[srcIndex + 12 + 0]]; + e[dstIndex + 10] = g[s[srcIndex + 12 + 1]]; + e[dstIndex + 9] = g[s[srcIndex + 12 + 2]]; + + srcIndex += 16; + dstIndex += 12; + } + + // Remainder + for (; x < w; x++) { - elements[x * 3 + 2] = gamma[sourceRow[x * 4 + 0]]; - elements[x * 3 + 1] = gamma[sourceRow[x * 4 + 1]]; - elements[x * 3 + 0] = gamma[sourceRow[x * 4 + 2]]; + e[dstIndex + 2] = g[s[srcIndex + 0]]; + e[dstIndex + 1] = g[s[srcIndex + 1]]; + e[dstIndex + 0] = g[s[srcIndex + 2]]; + srcIndex += 4; + dstIndex += 3; } } }); @@ -266,7 +304,7 @@ public void SaveToFile(string fileName, double gamma = 1) GeneralFileFormatHandler.SaveToFile(ToBitmapSource(96, 96, gamma), fileName); } - static float[] CreateDeGammaLookup(double gamma, int range) + internal static float[] CreateDeGammaLookup(double gamma, int range) { var gammaLUT = ArrayPool.Shared.Rent(range); var scale = 1.0 / (range - 1); @@ -374,12 +412,28 @@ public float Min() { fixed (float* elements = Elements) { - var size = Size; - for (var i = 0; i < size; i++) + var p = elements; + // Process in blocks of 8 to reduce loop overhead and branch misprediction + var remaining = Size; + while (remaining >= 8) { - var value = elements[i]; - min = Math.Min(min, value); - max = Math.Max(max, value); + var v0 = p[0]; if (v0 < min) min = v0; if (v0 > max) max = v0; + var v1 = p[1]; if (v1 < min) min = v1; if (v1 > max) max = v1; + var v2 = p[2]; if (v2 < min) min = v2; if (v2 > max) max = v2; + var v3 = p[3]; if (v3 < min) min = v3; if (v3 > max) max = v3; + var v4 = p[4]; if (v4 < min) min = v4; if (v4 > max) max = v4; + var v5 = p[5]; if (v5 < min) min = v5; if (v5 > max) max = v5; + var v6 = p[6]; if (v6 < min) min = v6; if (v6 > max) max = v6; + var v7 = p[7]; if (v7 < min) min = v7; if (v7 > max) max = v7; + p += 8; + remaining -= 8; + } + // Finish remaining elements + for (var i = 0; i < remaining; i++) + { + var value = *p++; + if (value < min) min = value; + if (value > max) max = value; } } } @@ -394,8 +448,16 @@ public double Mean() { fixed (float* elements = Elements) { - for (var i = 0; i < size; i++) - sum += elements[i]; + float* p = elements; + var remaining = size; + while (remaining >= 8) + { + sum += p[0] + p[1] + p[2] + p[3] + p[4] + p[5] + p[6] + p[7]; + p += 8; + remaining -= 8; + } + for (var i = 0; i < remaining; i++) + sum += *p++; } } return sum / size; diff --git a/PhotoLocatorTest/BitmapOperations/FloatBitmapTest.cs b/PhotoLocatorTest/BitmapOperations/FloatBitmapTest.cs index 9606503..50acf17 100644 --- a/PhotoLocatorTest/BitmapOperations/FloatBitmapTest.cs +++ b/PhotoLocatorTest/BitmapOperations/FloatBitmapTest.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Windows.Media; +using System.Windows.Media.Imaging; namespace PhotoLocator.BitmapOperations { @@ -38,9 +40,59 @@ public void FloatToByteGammaLutRange_ShouldBeSufficient() } [TestMethod] - public void MinMax_ShouldWork() + [DataRow(6, 4, 3, 1)] + [DataRow(6000, 4000, 3, 1)] + [DataRow(6000, 4000, 3, 2.2)] + public void Assign_ShouldAssign(int width, int height, int sourcePixelSize, double gamma) { - var floatBitmap = new FloatBitmap(10, 10, 3); + var format = sourcePixelSize switch + { + 1 => PixelFormats.Gray8, + 3 => PixelFormats.Rgb24, + 4 => PixelFormats.Cmyk32, + _ => throw new ArgumentException("Unsupported pixel size") + }; + var sourcePixels = new byte[width * height * sourcePixelSize]; + for (int i = 0; i < sourcePixels.Length; i++) + sourcePixels[i] = (byte)(i & 255); + var source = BitmapSource.Create(width, height, 96, 96, format, null, sourcePixels, width * sourcePixelSize); + + var floatBitmap = new FloatBitmap(); + var sw = Stopwatch.StartNew(); + floatBitmap.Assign(source, gamma); + Console.WriteLine(sw.ElapsedMilliseconds); + + Assert.AreEqual(width, floatBitmap.Width); + } + + [TestMethod] + [DataRow(6, 4, 1)] + [DataRow(6000, 4000, 1)] + [DataRow(6000, 4000, 2.2)] + public void Assign_ShouldAssignBgr32(int width, int height, double gamma) + { + var sourcePixels = new byte[width * height * 4]; + for (int i = 0; i < sourcePixels.Length; i++) + sourcePixels[i] = (byte)(i & 255); + var source = BitmapSource.Create(width, height, 96, 96, PixelFormats.Bgr32, null, sourcePixels, width * 4); + + var floatBitmap = new FloatBitmap(); + var sw = Stopwatch.StartNew(); + floatBitmap.Assign(source, gamma); + Console.WriteLine(sw.ElapsedMilliseconds); + + Assert.AreEqual(width, floatBitmap.Width); + + var gammaLut = FloatBitmap.CreateDeGammaLookup(gamma, 256); + Assert.AreEqual(gammaLut[sourcePixels[2]], floatBitmap.Elements[0, 0]); + Assert.AreEqual(gammaLut[sourcePixels[1]], floatBitmap.Elements[0, 1]); + Assert.AreEqual(gammaLut[sourcePixels[0]], floatBitmap.Elements[0, 2]); + } + + [TestMethod] + public void MinMax_ShouldReturnMinMax() + { + var floatBitmap = new FloatBitmap(6, 4, 3); floatBitmap[0, 0] = 1; var sw = Stopwatch.StartNew(); @@ -50,5 +102,18 @@ public void MinMax_ShouldWork() Assert.AreEqual(0, minMax.Min); Assert.AreEqual(1, minMax.Max); } + + [TestMethod] + public void Mean_ShouldReturnMean() + { + var floatBitmap = new FloatBitmap(6, 4, 3); + floatBitmap.ProcessElementWise(p => 1); + + var sw = Stopwatch.StartNew(); + var mean = floatBitmap.Mean(); + Console.WriteLine(sw.ElapsedMilliseconds); + + Assert.AreEqual(1, mean); + } } } From 8f4822b8f25c9f56a47f5f698b0478a9471b7b70 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Sun, 5 Apr 2026 12:59:55 +0200 Subject: [PATCH 02/10] Optimize ToPixels8 --- PhotoLocator/BitmapOperations/FloatBitmap.cs | 73 +++++++++--------- .../BitmapOperations/FloatBitmapTest.cs | 74 +++++++++++++------ 2 files changed, 87 insertions(+), 60 deletions(-) diff --git a/PhotoLocator/BitmapOperations/FloatBitmap.cs b/PhotoLocator/BitmapOperations/FloatBitmap.cs index 5114259..4068561 100644 --- a/PhotoLocator/BitmapOperations/FloatBitmap.cs +++ b/PhotoLocator/BitmapOperations/FloatBitmap.cs @@ -84,12 +84,12 @@ public void Assign(BitmapSource source, double gamma) Parallel.For(0, Height, y => { fixed (float* gamma = gammaLut) - fixed (float* elements = &Elements[y, 0]) - fixed (ushort* sourceRow = &sourcePixels[y * Stride]) + fixed (float* dstRow = &Elements[y, 0]) + fixed (ushort* srcRow = &sourcePixels[y * Stride]) { var stride = Stride; for (var x = 0; x < stride; x++) - elements[x] = gamma[sourceRow[x]]; + dstRow[x] = gamma[srcRow[x]]; } }); } @@ -106,14 +106,14 @@ public void Assign(BitmapSource source, double gamma) Parallel.For(0, Height, y => { fixed (float* gamma = gammaLut) - fixed (float* elements = &Elements[y, 0]) - fixed (byte* sourceRow = &sourcePixels[y * Stride]) + fixed (float* dstRow = &Elements[y, 0]) + fixed (byte* srcRow = &sourcePixels[y * Stride]) { for (var x = 0; x < Width; x++) { - elements[x * 3 + 2] = gamma[sourceRow[x * 3 + 0]]; - elements[x * 3 + 1] = gamma[sourceRow[x * 3 + 1]]; - elements[x * 3 + 0] = gamma[sourceRow[x * 3 + 2]]; + dstRow[x * 3 + 2] = gamma[srcRow[x * 3 + 0]]; + dstRow[x * 3 + 1] = gamma[srcRow[x * 3 + 1]]; + dstRow[x * 3 + 0] = gamma[srcRow[x * 3 + 2]]; } } }); @@ -133,39 +133,36 @@ public void Assign(BitmapSource source, double gamma) Parallel.For(0, Height, y => { fixed (float* gamma = gammaLut) - fixed (float* elements = &Elements[y, 0]) - fixed (byte* sourceRow = &sourcePixels[y * Width * 4]) + fixed (float* dstRow = &Elements[y, 0]) + fixed (byte* srcRow = &sourcePixels[y * Width * 4]) { var w = Width; var srcIndex = 0; var dstIndex = 0; - float* g = gamma; - float* e = elements; - byte* s = sourceRow; int x = 0; // Process 4 pixels per iteration to reduce loop overhead for (; x + 3 < w; x += 4) { // pixel 0 - e[dstIndex + 2] = g[s[srcIndex + 0]]; - e[dstIndex + 1] = g[s[srcIndex + 1]]; - e[dstIndex + 0] = g[s[srcIndex + 2]]; + dstRow[dstIndex + 2] = gamma[srcRow[srcIndex + 0]]; + dstRow[dstIndex + 1] = gamma[srcRow[srcIndex + 1]]; + dstRow[dstIndex + 0] = gamma[srcRow[srcIndex + 2]]; // pixel 1 - e[dstIndex + 5] = g[s[srcIndex + 4 + 0]]; - e[dstIndex + 4] = g[s[srcIndex + 4 + 1]]; - e[dstIndex + 3] = g[s[srcIndex + 4 + 2]]; + dstRow[dstIndex + 5] = gamma[srcRow[srcIndex + 4 + 0]]; + dstRow[dstIndex + 4] = gamma[srcRow[srcIndex + 4 + 1]]; + dstRow[dstIndex + 3] = gamma[srcRow[srcIndex + 4 + 2]]; // pixel 2 - e[dstIndex + 8] = g[s[srcIndex + 8 + 0]]; - e[dstIndex + 7] = g[s[srcIndex + 8 + 1]]; - e[dstIndex + 6] = g[s[srcIndex + 8 + 2]]; + dstRow[dstIndex + 8] = gamma[srcRow[srcIndex + 8 + 0]]; + dstRow[dstIndex + 7] = gamma[srcRow[srcIndex + 8 + 1]]; + dstRow[dstIndex + 6] = gamma[srcRow[srcIndex + 8 + 2]]; // pixel 3 - e[dstIndex + 11] = g[s[srcIndex + 12 + 0]]; - e[dstIndex + 10] = g[s[srcIndex + 12 + 1]]; - e[dstIndex + 9] = g[s[srcIndex + 12 + 2]]; + dstRow[dstIndex + 11] = gamma[srcRow[srcIndex + 12 + 0]]; + dstRow[dstIndex + 10] = gamma[srcRow[srcIndex + 12 + 1]]; + dstRow[dstIndex + 9] = gamma[srcRow[srcIndex + 12 + 2]]; srcIndex += 16; dstIndex += 12; @@ -174,9 +171,9 @@ public void Assign(BitmapSource source, double gamma) // Remainder for (; x < w; x++) { - e[dstIndex + 2] = g[s[srcIndex + 0]]; - e[dstIndex + 1] = g[s[srcIndex + 1]]; - e[dstIndex + 0] = g[s[srcIndex + 2]]; + dstRow[dstIndex + 2] = gamma[srcRow[srcIndex + 0]]; + dstRow[dstIndex + 1] = gamma[srcRow[srcIndex + 1]]; + dstRow[dstIndex + 0] = gamma[srcRow[srcIndex + 2]]; srcIndex += 4; dstIndex += 3; } @@ -206,12 +203,12 @@ public void Assign(BitmapSource source, double gamma) Parallel.For(0, Height, y => { fixed (float* gamma = gammaLut) - fixed (float* elements = &Elements[y, 0]) - fixed (byte* sourceRow = &sourcePixels[y * Stride]) + fixed (float* dstRow = &Elements[y, 0]) + fixed (byte* srcRow = &sourcePixels[y * Stride]) { var stride = Stride; for (var x = 0; x < stride; x++) - elements[x] = gamma[sourceRow[x]]; + dstRow[x] = gamma[srcRow[x]]; } }); } @@ -266,17 +263,19 @@ private byte[] ToPixels8(double gamma) var gammaLut = CreateGammaLookupFloatToByte(gamma); unsafe { - //gamma = 1 / gamma; Parallel.For(0, Height, y => { + var stride = Stride; fixed (byte* gamma = gammaLut) - fixed (float* elements = &Elements[y, 0]) - fixed (byte* destRow = &pixels[y * Stride]) + fixed (float* srcRow = &Elements[y, 0]) + fixed (byte* dstRow = &pixels[y * stride]) { - var stride = Stride; for (var x = 0; x < stride; x++) - //destRow[x] = (byte)IntMath.Clamp((int)(Math.Pow(elements[x], gamma) * 255 + 0.5), 0, 255); - destRow[x] = gamma[IntMath.Clamp((int)(elements[x] * FloatToByteGammaLutRange + 0.5f), 0, FloatToByteGammaLutRange)]; + { + int idx = (int)(srcRow[x] * FloatToByteGammaLutRange + 0.5f); + if (idx < 0) idx = 0; else if (idx > FloatToByteGammaLutRange) idx = FloatToByteGammaLutRange; + dstRow[x] = gamma[idx]; + } } }); } diff --git a/PhotoLocatorTest/BitmapOperations/FloatBitmapTest.cs b/PhotoLocatorTest/BitmapOperations/FloatBitmapTest.cs index 50acf17..8739e70 100644 --- a/PhotoLocatorTest/BitmapOperations/FloatBitmapTest.cs +++ b/PhotoLocatorTest/BitmapOperations/FloatBitmapTest.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using PhotoLocator.PictureFileFormats; +using System.Diagnostics; using System.Windows.Media; using System.Windows.Media.Imaging; @@ -40,48 +41,53 @@ public void FloatToByteGammaLutRange_ShouldBeSufficient() } [TestMethod] - [DataRow(6, 4, 3, 1)] - [DataRow(6000, 4000, 3, 1)] - [DataRow(6000, 4000, 3, 2.2)] - public void Assign_ShouldAssign(int width, int height, int sourcePixelSize, double gamma) + [DataRow(6, 4, 1, 2.2, 1)] + [DataRow(6, 4, 3, 2.2, 1)] + [DataRow(6, 4, 4, 2.2, 1)] + public void Assign_ShouldAssign(int width, int height, int planes, double gamma, int iterations) { - var format = sourcePixelSize switch + var format = planes switch { 1 => PixelFormats.Gray8, 3 => PixelFormats.Rgb24, 4 => PixelFormats.Cmyk32, _ => throw new ArgumentException("Unsupported pixel size") }; - var sourcePixels = new byte[width * height * sourcePixelSize]; + var sourcePixels = new byte[width * height * planes]; for (int i = 0; i < sourcePixels.Length; i++) sourcePixels[i] = (byte)(i & 255); - var source = BitmapSource.Create(width, height, 96, 96, format, null, sourcePixels, width * sourcePixelSize); + var source = BitmapSource.Create(width, height, 96, 96, format, null, sourcePixels, width * planes); - var floatBitmap = new FloatBitmap(); - var sw = Stopwatch.StartNew(); - floatBitmap.Assign(source, gamma); - Console.WriteLine(sw.ElapsedMilliseconds); + var floatBitmap = new FloatBitmap(width, height, planes); + for (int i = 0; i < iterations; i++) + { + var sw = Stopwatch.StartNew(); + floatBitmap.Assign(source, gamma); + Console.WriteLine(sw.ElapsedMilliseconds); + } - Assert.AreEqual(width, floatBitmap.Width); + var gammaLut = FloatBitmap.CreateDeGammaLookup(gamma, 256); + Assert.AreEqual(gammaLut[sourcePixels[0]], floatBitmap.Elements[0, 0]); + Assert.AreEqual(gammaLut[sourcePixels[1]], floatBitmap.Elements[0, 1]); + Assert.AreEqual(gammaLut[sourcePixels[2]], floatBitmap.Elements[0, 2]); } [TestMethod] - [DataRow(6, 4, 1)] - [DataRow(6000, 4000, 1)] - [DataRow(6000, 4000, 2.2)] - public void Assign_ShouldAssignBgr32(int width, int height, double gamma) + [DataRow(6, 4, 1, 1)] + public void Assign_ShouldAssignBgr32(int width, int height, double gamma, int iterations) { var sourcePixels = new byte[width * height * 4]; for (int i = 0; i < sourcePixels.Length; i++) sourcePixels[i] = (byte)(i & 255); var source = BitmapSource.Create(width, height, 96, 96, PixelFormats.Bgr32, null, sourcePixels, width * 4); - var floatBitmap = new FloatBitmap(); - var sw = Stopwatch.StartNew(); - floatBitmap.Assign(source, gamma); - Console.WriteLine(sw.ElapsedMilliseconds); - - Assert.AreEqual(width, floatBitmap.Width); + var floatBitmap = new FloatBitmap(width, height, 3); + for (int i = 0; i < iterations; i++) + { + var sw = Stopwatch.StartNew(); + floatBitmap.Assign(source, gamma); + Console.WriteLine(sw.ElapsedMilliseconds); + } var gammaLut = FloatBitmap.CreateDeGammaLookup(gamma, 256); Assert.AreEqual(gammaLut[sourcePixels[2]], floatBitmap.Elements[0, 0]); @@ -89,6 +95,28 @@ public void Assign_ShouldAssignBgr32(int width, int height, double gamma) Assert.AreEqual(gammaLut[sourcePixels[0]], floatBitmap.Elements[0, 2]); } + [TestMethod] + [DataRow(6, 4, 1, 2.2, 1, null)] + [DataRow(6, 4, 3, 2.2, 1, null)] + [DataRow(6, 4, 4, 2.2, 1, null)] + public void ToBitmapSource_ShouldCreateBitmapSource(int width, int height, int planes, double gamma, int iterations, string? fileName) + { + var floatBitmap = new FloatBitmap(width, height, planes); + var scale = 1.0f / width; + floatBitmap.ProcessElementWise((x, y) => x * scale); + for (int i = 0; i < iterations; i++) + { + var sw = Stopwatch.StartNew(); + var bitmap = floatBitmap.ToBitmapSource(96, 96, gamma); + Console.WriteLine(sw.ElapsedMilliseconds); + + Assert.AreEqual(width, bitmap.PixelWidth); + Assert.AreEqual(height, bitmap.PixelHeight); + if (fileName is not null) + GeneralFileFormatHandler.SaveToFile(bitmap, fileName); + } + } + [TestMethod] public void MinMax_ShouldReturnMinMax() { From 07d1250949a30d2b88e35c5c9ffe23bfd2e4ed8d Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Sun, 5 Apr 2026 17:53:44 +0200 Subject: [PATCH 03/10] ArrayPool in LanczosResizeOperation --- .../BitmapOperations/LanczosResizeOperation.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs b/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs index 2c6a216..847f448 100644 --- a/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs +++ b/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs @@ -1,9 +1,10 @@ -using System; +using PhotoLocator.Helpers; +using System; +using System.Buffers; using System.Threading; using System.Threading.Tasks; using System.Windows.Media; using System.Windows.Media.Imaging; -using PhotoLocator.Helpers; namespace PhotoLocator.BitmapOperations { @@ -290,12 +291,14 @@ public byte[] Apply(byte[] pixels, int width, int height, int planes, int pixelS else return null; - var pixels = new byte[width * height * pixelSize]; - source.CopyPixels(pixels, width * pixelSize, 0); + var srcPixels = ArrayPool.Shared.Rent(width * height * pixelSize); + source.CopyPixels(srcPixels, width * pixelSize, 0); - pixels = Apply(pixels, width, height, planes, pixelSize, newWidth, newHeight, ct); + var dstPixels = Apply(srcPixels, width, height, planes, pixelSize, newWidth, newHeight, ct); + ArrayPool.Shared.Return(srcPixels); - var result = BitmapSource.Create(newWidth, newHeight, newDpiX, newDpiY, source.Format, null, pixels, newWidth * pixelSize); + var result = BitmapSource.Create(newWidth, newHeight, newDpiX, newDpiY, source.Format, null, dstPixels, newWidth * pixelSize); + result.Freeze(); return result; } From 15b0eb3221b63d254ac187f0e60816c11b3235bb Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Mon, 6 Apr 2026 09:59:06 +0200 Subject: [PATCH 04/10] Optimize LanczosResizeOperation --- .../LanczosResizeOperation.cs | 78 ++++++++++++------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs b/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs index 847f448..65061f7 100644 --- a/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs +++ b/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs @@ -132,15 +132,11 @@ public unsafe void Apply(byte* source, int srcOffset, byte* dest, int dstOffset, class LineResamplerFixedPoint { - record struct SourcePixelWeight - { - public int SourceIndex; - public int SourceWeight; - } - + // Store indices and weights in separate arrays to reduce indirection and enable JIT optimization record struct Weight { - public SourcePixelWeight[] SourcePixelWeights; + public int[] SourceIndices; + public int[] SourceWeights; } readonly Weight[] _weights; @@ -156,23 +152,25 @@ internal LineResamplerFixedPoint(Func filterFunc, float filterWind { double sum = 0; float[] rawWeights; - SourcePixelWeight[] sourceWeights; + int[] sourceIndices; if (dstWidth < srcWidth) // Downscale { var center = i * scaleWN; var pMin = (int)Math.Floor(center - reduceWindow); var pMax = (int)Math.Ceiling(center + reduceWindow); - sourceWeights = new SourcePixelWeight[pMax - pMin + 1]; - rawWeights = new float[sourceWeights.Length]; + var len = pMax - pMin + 1; + sourceIndices = new int[len]; + rawWeights = new float[len]; for (var p = pMin; p <= pMax; p++) { + var idx = p - pMin; if (p < 0) - sourceWeights[p - pMin].SourceIndex = 0; + sourceIndices[idx] = 0; else if (p >= srcWidth) - sourceWeights[p - pMin].SourceIndex = (srcWidth - 1) * srcSampleDistance; + sourceIndices[idx] = (srcWidth - 1) * srcSampleDistance; else - sourceWeights[p - pMin].SourceIndex = p * srcSampleDistance; - sum += rawWeights[p - pMin] = filterFunc((p - center) * scaleNW) * scaleNW; + sourceIndices[idx] = p * srcSampleDistance; + sum += rawWeights[idx] = filterFunc((p - center) * scaleNW) * scaleNW; } } else // Upscale @@ -180,26 +178,35 @@ internal LineResamplerFixedPoint(Func filterFunc, float filterWind var center = i * scaleWN; var pMin = (int)Math.Floor(center - filterWindow); var pMax = (int)Math.Ceiling(center + filterWindow); - sourceWeights = new SourcePixelWeight[pMax - pMin + 1]; - rawWeights = new float[sourceWeights.Length]; + var len = pMax - pMin + 1; + sourceIndices = new int[len]; + rawWeights = new float[len]; for (var p = pMin; p <= pMax; p++) { + var idx = p - pMin; if (p < 0) - sourceWeights[p - pMin].SourceIndex = 0; + sourceIndices[idx] = 0; else if (p >= srcWidth) - sourceWeights[p - pMin].SourceIndex = (srcWidth - 1) * srcSampleDistance; + sourceIndices[idx] = (srcWidth - 1) * srcSampleDistance; else - sourceWeights[p - pMin].SourceIndex = p * srcSampleDistance; - sum += rawWeights[p - pMin] = filterFunc(p - center); + sourceIndices[idx] = p * srcSampleDistance; + sum += rawWeights[idx] = filterFunc(p - center); } } if (sum > 0) { var scale = 65536 / sum; - for (var p = 0; p < sourceWeights.Length; p++) - sourceWeights[p].SourceWeight = IntMath.Round(rawWeights[p] * scale); + var weightsInt = new int[rawWeights.Length]; + for (var p = 0; p < rawWeights.Length; p++) + weightsInt[p] = IntMath.Round(rawWeights[p] * scale); + _weights[i].SourceIndices = sourceIndices; + _weights[i].SourceWeights = weightsInt; + } + else + { + _weights[i].SourceIndices = Array.Empty(); + _weights[i].SourceWeights = Array.Empty(); } - _weights[i].SourcePixelWeights = sourceWeights; }); } @@ -209,15 +216,26 @@ public unsafe void Apply(byte* source, int srcOffset, byte* dest, int dstOffset, for (var i = 0; i < weights.Length; i++) { int sum = 32768; - int length = weights[i].SourcePixelWeights.Length; - fixed (SourcePixelWeight* sourceWeights = weights[i].SourcePixelWeights) + var sourceIndices = weights[i].SourceIndices; + var sourceWeights = weights[i].SourceWeights; + int length = sourceIndices.Length; + byte* sourcePixels = source + srcOffset; + int j = 0; + // Unroll loop to reduce per-iteration overhead and improve IL/JIT optimizations + for (; j + 3 < length; j += 4) + sum += sourcePixels[sourceIndices[j]] * sourceWeights[j] + + sourcePixels[sourceIndices[j + 1]] * sourceWeights[j + 1] + + sourcePixels[sourceIndices[j + 2]] * sourceWeights[j + 2] + + sourcePixels[sourceIndices[j + 3]] * sourceWeights[j + 3]; + for (; j < length; j++) + sum += sourcePixels[sourceIndices[j]] * sourceWeights[j]; + if (sum > 0) { - var sourceWeight = sourceWeights; - for (var j = 0; j < length; j++, sourceWeight++) - sum += source[srcOffset + (*sourceWeight).SourceIndex] * (*sourceWeight).SourceWeight; + sum >>= 16; + if (sum > 255) + sum = 255; + dest[dstOffset + i * dstSampleDistance] = (byte)sum; } - if (sum > 0) - dest[dstOffset + i * dstSampleDistance] = (byte)Math.Min(sum >> 16, 255); } } } From 33997d4913717dd5aa38880324a54c8c40c41c0a Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Wed, 8 Apr 2026 22:47:55 +0200 Subject: [PATCH 05/10] Revert unrolling --- PhotoLocator/BitmapOperations/FloatBitmap.cs | 50 ++++---------------- 1 file changed, 9 insertions(+), 41 deletions(-) diff --git a/PhotoLocator/BitmapOperations/FloatBitmap.cs b/PhotoLocator/BitmapOperations/FloatBitmap.cs index 4068561..bcc70db 100644 --- a/PhotoLocator/BitmapOperations/FloatBitmap.cs +++ b/PhotoLocator/BitmapOperations/FloatBitmap.cs @@ -109,7 +109,8 @@ public void Assign(BitmapSource source, double gamma) fixed (float* dstRow = &Elements[y, 0]) fixed (byte* srcRow = &sourcePixels[y * Stride]) { - for (var x = 0; x < Width; x++) + var width = Width; + for (var x = 0; x < width; x++) { dstRow[x * 3 + 2] = gamma[srcRow[x * 3 + 0]]; dstRow[x * 3 + 1] = gamma[srcRow[x * 3 + 1]]; @@ -136,46 +137,12 @@ public void Assign(BitmapSource source, double gamma) fixed (float* dstRow = &Elements[y, 0]) fixed (byte* srcRow = &sourcePixels[y * Width * 4]) { - var w = Width; - var srcIndex = 0; - var dstIndex = 0; - int x = 0; - - // Process 4 pixels per iteration to reduce loop overhead - for (; x + 3 < w; x += 4) - { - // pixel 0 - dstRow[dstIndex + 2] = gamma[srcRow[srcIndex + 0]]; - dstRow[dstIndex + 1] = gamma[srcRow[srcIndex + 1]]; - dstRow[dstIndex + 0] = gamma[srcRow[srcIndex + 2]]; - - // pixel 1 - dstRow[dstIndex + 5] = gamma[srcRow[srcIndex + 4 + 0]]; - dstRow[dstIndex + 4] = gamma[srcRow[srcIndex + 4 + 1]]; - dstRow[dstIndex + 3] = gamma[srcRow[srcIndex + 4 + 2]]; - - // pixel 2 - dstRow[dstIndex + 8] = gamma[srcRow[srcIndex + 8 + 0]]; - dstRow[dstIndex + 7] = gamma[srcRow[srcIndex + 8 + 1]]; - dstRow[dstIndex + 6] = gamma[srcRow[srcIndex + 8 + 2]]; - - // pixel 3 - dstRow[dstIndex + 11] = gamma[srcRow[srcIndex + 12 + 0]]; - dstRow[dstIndex + 10] = gamma[srcRow[srcIndex + 12 + 1]]; - dstRow[dstIndex + 9] = gamma[srcRow[srcIndex + 12 + 2]]; - - srcIndex += 16; - dstIndex += 12; - } - - // Remainder - for (; x < w; x++) + var width = Width; + for (var x = 0; x < width; x++) { - dstRow[dstIndex + 2] = gamma[srcRow[srcIndex + 0]]; - dstRow[dstIndex + 1] = gamma[srcRow[srcIndex + 1]]; - dstRow[dstIndex + 0] = gamma[srcRow[srcIndex + 2]]; - srcIndex += 4; - dstIndex += 3; + dstRow[x * 3 + 2] = gamma[srcRow[x * 4 + 0]]; + dstRow[x * 3 + 1] = gamma[srcRow[x * 4 + 1]]; + dstRow[x * 3 + 0] = gamma[srcRow[x * 4 + 2]]; } } }); @@ -227,7 +194,8 @@ public void Assign(BitmapPlaneInt16 src, Func remap) fixed (Int16* srcPix = &src.Elements[y, 0]) fixed (float* dstPix = &Elements[y, 0]) { - for (int x = 0; x < Width; x++) + var width = Width; + for (int x = 0; x < width; x++) dstPix[x] = remap(srcPix[x]); } }); From 3ad7470c9b708615c56f8d5f4dd9bbe29ccfc2e4 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Fri, 10 Apr 2026 22:42:35 +0200 Subject: [PATCH 06/10] Max op test --- .../BitmapOperations/MaxFramesOperation.cs | 5 ++++ PhotoLocator/Controls/CropControl.xaml.cs | 2 +- .../MaxFramesOperationTest.cs | 23 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/PhotoLocator/BitmapOperations/MaxFramesOperation.cs b/PhotoLocator/BitmapOperations/MaxFramesOperation.cs index a55014f..5868f68 100644 --- a/PhotoLocator/BitmapOperations/MaxFramesOperation.cs +++ b/PhotoLocator/BitmapOperations/MaxFramesOperation.cs @@ -15,6 +15,11 @@ public MaxFramesOperation(string? darkFramePath, CombineFramesRegistration? regi public override void ProcessImage(BitmapSource image) { var pixels = PrepareFrame(image); + if (ProcessedImages == 1) + { + Parallel.For(0, pixels.Length, i => _accumulatorPixels[i] = pixels[i]); + return; + } Parallel.For(0, pixels.Length, i => { if (pixels[i] > _accumulatorPixels![i]) diff --git a/PhotoLocator/Controls/CropControl.xaml.cs b/PhotoLocator/Controls/CropControl.xaml.cs index d16725f..e5451a5 100644 --- a/PhotoLocator/Controls/CropControl.xaml.cs +++ b/PhotoLocator/Controls/CropControl.xaml.cs @@ -46,7 +46,7 @@ public void Reset(BitmapSource? image, double imageScale, double cropWidthHeight } catch { } // Ignore unsupported pixel formats } - CropBorderColor = new SolidColorBrush(pixelMean > 0.2 ? Color.FromArgb(128, 0, 0, 0) : Color.FromArgb(64, 255, 255, 255)); + CropBorderColor = new SolidColorBrush(pixelMean > 0.25 ? Color.FromArgb(128, 0, 0, 0) : Color.FromArgb(64, 255, 255, 255)); Width = _imageWidth * imageScale; Height = _imageHeight * imageScale; var imageWidthHeightRatio = (double)_imageWidth / _imageHeight; diff --git a/PhotoLocatorTest/BitmapOperations/MaxFramesOperationTest.cs b/PhotoLocatorTest/BitmapOperations/MaxFramesOperationTest.cs index 97a834e..b142761 100644 --- a/PhotoLocatorTest/BitmapOperations/MaxFramesOperationTest.cs +++ b/PhotoLocatorTest/BitmapOperations/MaxFramesOperationTest.cs @@ -6,6 +6,29 @@ namespace PhotoLocator.BitmapOperations; [TestClass] public class MaxFramesOperationTest { + [TestMethod] + [DataRow(0, 1)] + [DataRow(0.5f, 0)] + public void GetResult8_ShouldReturnMax(float firstImageValue, float secondImageValue) + { + var floatImage1 = new FloatBitmap(6, 4, 3); + floatImage1.ProcessElementWise(p => firstImageValue); + var floatImage2 = new FloatBitmap(floatImage1.Width, floatImage1.Height, floatImage1.PlaneCount); + floatImage2.ProcessElementWise(p => secondImageValue); + + var op = new MaxFramesOperation(null, null, default); + var sw = Stopwatch.StartNew(); + op.ProcessImage(floatImage1.ToBitmapSource(96, 96, 1)); + op.ProcessImage(floatImage2.ToBitmapSource(96, 96, 1)); + Console.WriteLine(sw.ElapsedMilliseconds); + + Assert.IsTrue(op.IsResultReady); + Assert.AreEqual(2, op.ProcessedImages); + + var result = new FloatBitmap(op.GetResult8(), 1); + Assert.AreEqual(Math.Max(firstImageValue, secondImageValue), result.Mean(), 0.01); + } + [TestMethod] public void ProcessImage_ShouldUseDarkFrame() { From 6c38910c3672f6aee5680d0eb1af91a2920989c3 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Fri, 10 Apr 2026 23:16:23 +0200 Subject: [PATCH 07/10] Optimize ColorToneAdjustOperation --- .../ColorToneAdjustOperation.cs | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/PhotoLocator/BitmapOperations/ColorToneAdjustOperation.cs b/PhotoLocator/BitmapOperations/ColorToneAdjustOperation.cs index 58edfe4..1bf0e6e 100644 --- a/PhotoLocator/BitmapOperations/ColorToneAdjustOperation.cs +++ b/PhotoLocator/BitmapOperations/ColorToneAdjustOperation.cs @@ -104,9 +104,9 @@ public static void ColorTransformHSI2RGB(float h, float s, float i, out float r, public static void ColorTransformRGB2HSI(float r, float g, float b, out float h, out float s, out float i) { - r = RealMath.Clamp(r, 0f, 1f); - g = RealMath.Clamp(g, 0f, 1f); - b = RealMath.Clamp(b, 0f, 1f); + r = Math.Clamp(r, 0f, 1f); + g = Math.Clamp(g, 0f, 1f); + b = Math.Clamp(b, 0f, 1f); i = (r + g + b) / 3f; if (i == 0) { @@ -115,17 +115,17 @@ public static void ColorTransformRGB2HSI(float r, float g, float b, out float h, } else { - var D = r; - if (g < D) - D = g; - if (b < D) - D = b; - s = Math.Max(0, 1 - 3f / (r + g + b) * D); + var min = r; + if (g < min) + min = g; + if (b < min) + min = b; + s = Math.Max(0, 1 - 3f / (r + g + b) * min); if (s == 0) h = 0; else { - var a = 0.5 * (r - g + (r - b)) / Math.Sqrt(RealMath.Sqr(r - g) + (r - b) * (g - b)); + var a = 0.5 * (r - g + (r - b)) / Math.Sqrt((r - g) * (r - g) + (r - b) * (g - b)); double rh; if (a <= -1) rh = Math.PI; @@ -177,13 +177,14 @@ public override void Apply() Parallel.For(0, _srcHSI.Height, y => { var toneAdjustments = ToneAdjustments; + var width = _srcHSI.Width; unsafe { fixed (float* src = &_srcHSI.Elements[y, 0]) fixed (float* dst = &DstBitmap.Elements[y, 0]) { int xx = 0; - for (var x = 0; x < _srcHSI.Width; x++) + for (var x = 0; x < width; x++) { var tone = (src[xx] - Rotation) * NumberOfTones; if (tone < 0) From d011ae4dc31b93960cb7325777d86bf30716d559 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Wed, 15 Apr 2026 20:55:10 +0200 Subject: [PATCH 08/10] size --- PhotoLocator/BitmapOperations/FloatBitmap.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PhotoLocator/BitmapOperations/FloatBitmap.cs b/PhotoLocator/BitmapOperations/FloatBitmap.cs index bcc70db..66f12e2 100644 --- a/PhotoLocator/BitmapOperations/FloatBitmap.cs +++ b/PhotoLocator/BitmapOperations/FloatBitmap.cs @@ -217,8 +217,8 @@ public BitmapSource ToBitmapSource(double dpiX, double dpiY, double gamma, Pixel return (bitmap, Task.Run(() => { var histogram = new int[256]; - var length = Height * Stride; - for (int i = 0; i < length; i++) + var size = Size; + for (int i = 0; i < size; i++) histogram[pixels[i]]++; ArrayPool.Shared.Return(pixels); return histogram; From 09be5b4c31c1eea1d77b2bf5f911670aab0c4009 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Fri, 17 Apr 2026 21:16:15 +0200 Subject: [PATCH 09/10] Return after use --- PhotoLocator/BitmapOperations/LanczosResizeOperation.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs b/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs index 65061f7..3257f75 100644 --- a/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs +++ b/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs @@ -313,10 +313,9 @@ public byte[] Apply(byte[] pixels, int width, int height, int planes, int pixelS source.CopyPixels(srcPixels, width * pixelSize, 0); var dstPixels = Apply(srcPixels, width, height, planes, pixelSize, newWidth, newHeight, ct); - ArrayPool.Shared.Return(srcPixels); - var result = BitmapSource.Create(newWidth, newHeight, newDpiX, newDpiY, source.Format, null, dstPixels, newWidth * pixelSize); - + ArrayPool.Shared.Return(srcPixels); // Apply may return srcPixels so only return after creating result + result.Freeze(); return result; } From fe8c91b8729482d8a0c7a3c2014c07f1e3f43134 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Fri, 17 Apr 2026 21:41:32 +0200 Subject: [PATCH 10/10] Cleanup --- .../BitmapOperations/ColorToneAdjustOperationTest.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/PhotoLocatorTest/BitmapOperations/ColorToneAdjustOperationTest.cs b/PhotoLocatorTest/BitmapOperations/ColorToneAdjustOperationTest.cs index dbb8402..b256610 100644 --- a/PhotoLocatorTest/BitmapOperations/ColorToneAdjustOperationTest.cs +++ b/PhotoLocatorTest/BitmapOperations/ColorToneAdjustOperationTest.cs @@ -1,7 +1,6 @@ using PhotoLocator.PictureFileFormats; using System.Diagnostics; using System.Windows.Media.Imaging; -using static PhotoLocator.BitmapOperations.ColorToneAdjustOperation; namespace PhotoLocator.BitmapOperations {