From dc1ddda95832269e03f80d2d53f26bb649816673 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Sun, 27 Sep 2026 00:13:02 +0200 Subject: [PATCH 1/9] Improve astro stretch background removal --- .../BitmapOperations/AstroStretchOperation.cs | 55 ++++-- .../BilinearResizeOperation.cs | 2 + .../CombineFramesOperationBase.cs | 70 +------- .../BitmapOperations/HoleClosingOperation.cs | 139 ++++++++++++++ .../BitmapOperations/IIRSmoothOperation.cs | 169 +++++++++++------- .../LanczosResizeOperation.cs | 58 ++++++ PhotoLocator/LocalContrastView.xaml | 2 +- PhotoLocator/LocalContrastViewModel.cs | 2 +- PhotoLocator/Metadata/ExifHandler.cs | 2 + PhotoLocator/Metadata/MaskBasedNaming.cs | 32 +++- .../MaxFramesOperationTest.cs | 8 +- 11 files changed, 393 insertions(+), 146 deletions(-) create mode 100644 PhotoLocator/BitmapOperations/HoleClosingOperation.cs diff --git a/PhotoLocator/BitmapOperations/AstroStretchOperation.cs b/PhotoLocator/BitmapOperations/AstroStretchOperation.cs index 4aa1007..3dc8d95 100644 --- a/PhotoLocator/BitmapOperations/AstroStretchOperation.cs +++ b/PhotoLocator/BitmapOperations/AstroStretchOperation.cs @@ -6,7 +6,7 @@ class AstroStretchOperation : OperationBase { public double Stretch { get; set; } = 10; - public double BackgroundSmooth { get; set; } = 8; + public double BackgroundSmooth { get; set; } = 2; public double BlackPoint { get; set; } @@ -21,15 +21,50 @@ public override void Apply() } if (BackgroundSmooth > 0) { - var background = ConvertToGrayscaleOperation.ConvertToGrayscale(DstBitmap); - IIRSmoothOperation.Apply(background, (float)Math.Exp(BackgroundSmooth)); - if (BlackPoint > 0) - { - var bp = (float)BlackPoint; - DstBitmap.ProcessElementWise(background, (p, b) => Math.Max(p - b - bp, 0)); - } - else - DstBitmap.ProcessElementWise(background, (p, b) => Math.Max(p - b, 0)); + //var background = SplitBackgroundChannels ? new FloatBitmap(DstBitmap) : ConvertToGrayscaleOperation.ConvertToGrayscale(DstBitmap); + //IIRSmoothOperation.Apply(background, (float)Math.Exp(BackgroundSmooth)); + //if (BlackPoint > 0) + //{ + // var bp = (float)BlackPoint; + // DstBitmap.ProcessElementWise(background, (p, b) => Math.Max(p - b - bp, 0)); + //} + //else + // DstBitmap.ProcessElementWise(background, (p, b) => Math.Max(p - b, 0)); + + //var background = ConvertToGrayscaleOperation.ConvertToGrayscale(DstBitmap); + //IIRSmoothOperation.Apply(background, (float)Math.Exp(BackgroundSmooth)); + //var mask = ConvertToGrayscaleOperation.ConvertToGrayscale(DstBitmap); + //mask.ProcessElementWise(background, (p, b) => p - b > BlackPoint ? 1 : 0); + //background = SplitBackgroundChannels ? new FloatBitmap(DstBitmap) : ConvertToGrayscaleOperation.ConvertToGrayscale(DstBitmap); + ////mask.SaveToFile("astroMask.jpg"); + //HoleClosingOperation.CloseHolesIteratively(background, mask, 0); + //IIRSmoothOperation.Apply(background, (float)Math.Exp(BackgroundSmooth)); + //DstBitmap.ProcessElementWise(background, (p, b) => Math.Max(p - b, 0)); + + const int BackgroundWidth = 128; + var resizeOp = new LanczosResizeOperation { FilterFunc = LanczosResizeOperation.Lanczos2, FilterWindow = 1 }; + var backgroundReduced = resizeOp.Apply(DstBitmap, BackgroundWidth, (int)Math.Ceiling((double)BackgroundWidth / DstBitmap.Width * DstBitmap.Height)); + //backgroundReduced.SaveToFile("astroReducedBackground.jpg", FloatBitmap.DefaultMonitorGamma); + + var mask = ConvertToGrayscaleOperation.ConvertToGrayscale(backgroundReduced); + var smoothBackground = new FloatBitmap(mask); + IIRSmoothOperation.Apply(smoothBackground, BackgroundWidth * BackgroundSmooth); + mask.ProcessElementWise(smoothBackground, (m, s) => m - s > 0 ? 1 : 0); + + //mask.SaveToFile("astroMask.jpg"); + + HoleClosingOperation.CloseHolesIteratively(backgroundReduced, mask, 0); + //backgroundReduced.SaveToFile("astroReducedBackgroundClosed.jpg", FloatBitmap.DefaultMonitorGamma); + + IIRSmoothOperation.Apply(backgroundReduced, BackgroundWidth * BackgroundSmooth); + + var background = resizeOp.Apply(backgroundReduced, DstBitmap.Width, DstBitmap.Height); + //background.SaveToFile("astroBackgroundClosed.jpg", FloatBitmap.DefaultMonitorGamma); + + //IIRSmoothOperation.Apply(background, (float)Math.Exp(BackgroundSmooth)); + + var bp = (float)BlackPoint; + DstBitmap.ProcessElementWise(background, (p, b) => Math.Max(p - b - bp, 0)); } else if (BlackPoint > 0) { diff --git a/PhotoLocator/BitmapOperations/BilinearResizeOperation.cs b/PhotoLocator/BitmapOperations/BilinearResizeOperation.cs index 7e0937a..6322b7c 100644 --- a/PhotoLocator/BitmapOperations/BilinearResizeOperation.cs +++ b/PhotoLocator/BitmapOperations/BilinearResizeOperation.cs @@ -162,6 +162,8 @@ public static void ApplyToPlane(BitmapPlaneInt16 srcPlane, BitmapPlaneInt16 dstP public static void ApplyToPlaneParallel(FloatBitmap srcPlane, FloatBitmap dstPlane, ROI dstROI) { + Debug.Assert(srcPlane.PlaneCount == 1); + Debug.Assert(dstPlane.PlaneCount == 1); Debug.Assert(dstROI.Right <= dstPlane.Width && dstROI.Bottom <= dstPlane.Height, "Invalid ROI"); float scaleX = 0, scaleY = 0; if (dstPlane.Width > 1) diff --git a/PhotoLocator/BitmapOperations/CombineFramesOperationBase.cs b/PhotoLocator/BitmapOperations/CombineFramesOperationBase.cs index 3056b4e..89ce76c 100644 --- a/PhotoLocator/BitmapOperations/CombineFramesOperationBase.cs +++ b/PhotoLocator/BitmapOperations/CombineFramesOperationBase.cs @@ -157,13 +157,7 @@ protected byte[] PrepareFrame(BitmapSource image) return pixels; } - readonly struct HotPixel - { - public int Target { get; init; } - public int Source { get; init; } - } - - List? _hotPixels; + List? _hotPixels; private void SubtractDarkFrame(byte[] pixels) { @@ -171,63 +165,15 @@ private void SubtractDarkFrame(byte[] pixels) return; //Parallel.For(0, pixels.Length, i => pixels[i] = (byte)Math.Max(0, pixels[i] - _darkFramePixels[i])); - _hotPixels ??= FindHotPixels(); + if (_hotPixels is null) + { + _hotPixels = HoleClosingOperation.FindHolePatches(_darkFramePixels, Width, Height, PixelSize, 32, _ct); + Log.Write($"Found {_hotPixels.Count} hot pixels in dark frame"); + if (_hotPixels.Count > Width * Height * PixelSize / 2) + throw new UserMessageException("Bad dark frame, too many hot pixels"); + } foreach (var hotpixel in _hotPixels) pixels[hotpixel.Target] = pixels[hotpixel.Source]; } - - private List FindHotPixels() - { - const int HotPixelThreshold = 32; - - if (_darkFramePixels is null) - throw new InvalidOperationException("Dark frame not set"); - var hotPixels = new List(); - int stride = Width * PixelSize; - Parallel.For(0, Height, new ParallelOptions { CancellationToken = _ct }, y => - { - var rowStart = y * stride; - for (int x = 0; x < stride; x++) - if (_darkFramePixels[rowStart + x] > HotPixelThreshold) - { - bool found = false; - for (int radius = 1; !found && radius < 100; radius++) - for (int d = 0; !found && d < radius; d++) - // ^ <1 5> - // 7 ^ - // 3 * 4 - // · 8 - // <6 2> · - found = - CheckCandidate(x - d * PixelSize, y - radius) || // 1 - CheckCandidate(x + d * PixelSize, y + radius) || // 2 - CheckCandidate(x - radius * PixelSize, y + d) || // 3 - CheckCandidate(x + radius * PixelSize, y - d) || // 4 - CheckCandidate(x + (d + 1) * PixelSize, y - radius) || // 5 - CheckCandidate(x - (d + 1) * PixelSize, y + radius) || // 6 - CheckCandidate(x - radius * PixelSize, y - (d + 1)) || // 7 - CheckCandidate(x + radius * PixelSize, y + d + 1); // 8 - if (!found) - throw new UserMessageException("Bad dark frame, unable to patch hot pixel"); - - bool CheckCandidate(int sx, int sy) - { - if (sx < 0 || sy < 0 || sx >= stride || sy >= Height || _darkFramePixels[sy * stride + sx] > HotPixelThreshold) - return false; - lock (hotPixels) - hotPixels.Add(new HotPixel - { - Target = rowStart + x, - Source = sy * stride + sx, - }); - return true; - } - } - }); - Log.Write($"Found {hotPixels.Count} hot pixels in dark frame"); - if (hotPixels.Count > Width * Height * PixelSize / 2) - throw new UserMessageException("Bad dark frame, too many hot pixels"); - return hotPixels; - } } } \ No newline at end of file diff --git a/PhotoLocator/BitmapOperations/HoleClosingOperation.cs b/PhotoLocator/BitmapOperations/HoleClosingOperation.cs new file mode 100644 index 0000000..d62c1ad --- /dev/null +++ b/PhotoLocator/BitmapOperations/HoleClosingOperation.cs @@ -0,0 +1,139 @@ +using PhotoLocator.Helpers; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PhotoLocator.BitmapOperations; + +static class HoleClosingOperation +{ + public readonly struct HolePatch + { + public int Target { get; init; } + public int Source { get; init; } + } + + public static List FindHolePatches(byte[] mask, int width, int height, int planeCount, int holeThreshold, CancellationToken ct) + { + var holePatches = new List(); + int stride = width * planeCount; + Parallel.For(0, height, new ParallelOptions { CancellationToken = ct }, y => + { + var rowStart = y * stride; + for (int x = 0; x < stride; x++) + if (mask[rowStart + x] > holeThreshold) + { + bool found = false; + for (int radius = 1; !found && radius < 100; radius++) + for (int d = 0; !found && d < radius; d++) + // ^ <1 5> + // 7 ^ + // 3 * 4 + // · 8 + // <6 2> · + found = + CheckCandidate(x - d * planeCount, y - radius) || // 1 + CheckCandidate(x + d * planeCount, y + radius) || // 2 + CheckCandidate(x - radius * planeCount, y + d) || // 3 + CheckCandidate(x + radius * planeCount, y - d) || // 4 + CheckCandidate(x + (d + 1) * planeCount, y - radius) || // 5 + CheckCandidate(x - (d + 1) * planeCount, y + radius) || // 6 + CheckCandidate(x - radius * planeCount, y - (d + 1)) || // 7 + CheckCandidate(x + radius * planeCount, y + d + 1); // 8 + if (!found) + throw new UserMessageException("Bad dark frame, unable to patch hot pixel"); + + bool CheckCandidate(int sx, int sy) + { + if (sx < 0 || sy < 0 || sx >= stride || sy >= height || mask[sy * stride + sx] > holeThreshold) + return false; + lock (holePatches) + holePatches.Add(new HolePatch + { + Target = rowStart + x, + Source = sy * stride + sx, + }); + return true; + } + } + }); + return holePatches; + } + + /// + /// Fill holes in image where the corresponding pixel in the grayscale mask is above threshold + /// + /// Color or grayscale image + /// Grayscale mask + /// Threshold in mask + public static void CloseHolesIteratively(FloatBitmap image, FloatBitmap mask, float threshold) + { + if (image.Width != mask.Width || image.Height != mask.Height) + throw new ArgumentException("Image and mask dimensions must match."); + + var width = image.Width; + var height = image.Height; + var planeCount = image.PlaneCount; + var holes = new bool[width * height]; + var remaining = 0; + + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + if (mask[x, y] > threshold) + { + holes[y * width + x] = true; + remaining++; + } + Log.Write($"Detected {remaining * 100 / holes.Length}% holes in mask"); + + while (remaining > 0) + { + var replacements = new List<(int X, int Y, float[] Values)>(); + + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + { + if (!holes[y * width + x]) + continue; + + var sums = new float[planeCount]; + var count = 0; + for (var dy = -1; dy <= 1; dy++) + for (var dx = -1; dx <= 1; dx++) + { + if (dx == 0 && dy == 0) + continue; + + var nx = x + dx; + var ny = y + dy; + if (nx < 0 || nx >= width || ny < 0 || ny >= height || holes[ny * width + nx]) + continue; + + for (var p = 0; p < planeCount; p++) + sums[p] += image.Elements[ny, nx * planeCount + p]; + count++; + } + + if (count > 0) + { + for (var p = 0; p < planeCount; p++) + sums[p] /= count; + replacements.Add((x, y, sums)); + } + } + + if (replacements.Count == 0) + break; + + foreach (var replacement in replacements) + { + var offset = replacement.X * planeCount; + for (var p = 0; p < planeCount; p++) + image.Elements[replacement.Y, offset + p] = replacement.Values[p]; + holes[replacement.Y * width + replacement.X] = false; + } + remaining -= replacements.Count; + } + } +} diff --git a/PhotoLocator/BitmapOperations/IIRSmoothOperation.cs b/PhotoLocator/BitmapOperations/IIRSmoothOperation.cs index 55b76ed..780d152 100644 --- a/PhotoLocator/BitmapOperations/IIRSmoothOperation.cs +++ b/PhotoLocator/BitmapOperations/IIRSmoothOperation.cs @@ -1,5 +1,4 @@ -using System.Diagnostics; -using System.Numerics; +using System.Numerics; using System.Runtime.Intrinsics; using System.Threading.Tasks; @@ -7,92 +6,126 @@ namespace PhotoLocator.BitmapOperations { public static class IIRSmoothOperation { - public static void Apply(FloatBitmap plane, float filterSize) + public static void Apply(FloatBitmap image, double filterSize) { if (filterSize == 0) return; - Debug.Assert(plane.PlaneCount == 1); - filterSize /= 4f; - var scale = 1f / (1f + filterSize); - var height = plane.Height; - unsafe + filterSize /= 4; + var scale = 1 / (1 + filterSize); + if (image.PlaneCount == 1) + ApplyHorizontalSmoothSinglePlane(image, filterSize, scale); + else + ApplyHorizontalSmoothMultiplePlanesPlane(image, filterSize, scale); + ApplyVerticalSmooth(image, (float)filterSize, (float)scale); + } + + private static unsafe void ApplyHorizontalSmoothSinglePlane(FloatBitmap plane, double filterSize, double scale) + { + Parallel.For(0, plane.Height, y => { - // Horizontal smooth - Parallel.For(0, height, y => + var stride = plane.Stride; + fixed (float* pixels = &plane.Elements[y, 0]) { - var stride = plane.Stride; - fixed (float* pixels = plane.Elements) + var pix = pixels; + double value = *pix; + for (var x = stride; x > 1; x--) + { + pix++; + value = (value * filterSize + *pix) * scale; + *pix = (float)value; + } + for (var x = stride; x > 1; x--) + { + pix--; + value = (value * filterSize + *pix) * scale; + *pix = (float)value; + } + } + }); + } + + private static unsafe void ApplyHorizontalSmoothMultiplePlanesPlane(FloatBitmap plane, double filterSize, double scale) + { + Parallel.For(0, plane.Height, y => + { + var width = plane.Width; + var planeCount = plane.PlaneCount; + for (int p = 0; p < planeCount; p++) + fixed (float* pixels = &plane.Elements[y, p]) { - var pix = &pixels[y * stride]; - var value = *pix; - for (var x = stride; x > 1; x--) + var pix = pixels; + double value = *pix; + for (var x = width; x > 1; x--) { - pix++; + pix += planeCount; value = (value * filterSize + *pix) * scale; - *pix = value; + *pix = (float)value; } - for (var x = stride; x > 1; x--) + for (var x = width; x > 1; x--) { - pix--; + pix -= planeCount; value = (value * filterSize + *pix) * scale; - *pix = value; + *pix = (float)value; } } - }); + }); + } - // Vertical smooth - vectorized over columns when possible - int vectorSize = Vector.Count; - int vectorizedSegments = plane.Stride / vectorSize; - var filterSizeV = Vector.Create(filterSize); - var scaleV = Vector.Create(scale); - Parallel.For(0, vectorizedSegments, vi => + private static unsafe void ApplyVerticalSmooth(FloatBitmap plane, float filterSize, float scale) + { + var height = plane.Height; + // Vectorized over columns when possible + int vectorSize = Vector.Count; + int vectorizedSegments = plane.Stride / vectorSize; + var filterSizeV = Vector.Create(filterSize); + var scaleV = Vector.Create(scale); + Parallel.For(0, vectorizedSegments, vi => + { + int x = vi * vectorSize; + var stride = plane.Stride; + fixed (float* pixels = plane.Elements) { - int x = vi * vectorSize; - var stride = plane.Stride; - fixed (float* pixels = plane.Elements) + float* colPtr = &pixels[x]; + var v = Vector.Load(colPtr); + for (var y = height; y > 1; y--) { - float* colPtr = &pixels[x]; - var v = Vector.Load(colPtr); - for (var y = height; y > 1; y--) - { - colPtr += stride; - var inV = Vector.Load(colPtr); - v = Vector.Multiply(Vector.Add(Vector.Multiply(v, filterSizeV), inV), scaleV); - v.Store(colPtr); - } - for (var y = height; y > 1; y--) - { - colPtr -= stride; - var inV = Vector.Load(colPtr); - v = Vector.Multiply(Vector.Add(Vector.Multiply(v, filterSizeV), inV), scaleV); - v.Store(colPtr); - } + colPtr += stride; + var inV = Vector.Load(colPtr); + v = Vector.Multiply(Vector.Add(Vector.Multiply(v, filterSizeV), inV), scaleV); + v.Store(colPtr); } - }); + for (var y = height; y > 1; y--) + { + colPtr -= stride; + var inV = Vector.Load(colPtr); + v = Vector.Multiply(Vector.Add(Vector.Multiply(v, filterSizeV), inV), scaleV); + v.Store(colPtr); + } + } + }); - // Remaining columns - columns not divisible by vectorSize - Parallel.For(vectorizedSegments * vectorSize, plane.Stride, x => + // Remaining columns - columns not divisible by vectorSize + Parallel.For(vectorizedSegments * vectorSize, plane.Stride, x => + { + var stride = plane.Stride; + fixed (float* pixels = plane.Elements) { - var stride = plane.Stride; - fixed (float* pixels = plane.Elements) + var pix = &pixels[x]; + var value = *pix; + for (var y = height; y > 1; y--) { - var pix = &pixels[x]; - var value = *pix; - for (var y = height; y > 1; y--) - { - pix += stride; - value = (value * filterSize + *pix) * scale; - *pix = value; - } - for (var y = height; y > 1; y--) - { - pix -= stride; - value = (value * filterSize + *pix) * scale; - *pix = value; - } + pix += stride; + value = (value * filterSize + *pix) * scale; + *pix = value; } - }); - } - } + for (var y = height; y > 1; y--) + { + pix -= stride; + value = (value * filterSize + *pix) * scale; + *pix = value; + } + } + }); + } } } diff --git a/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs b/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs index 6f372e0..aafa11a 100644 --- a/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs +++ b/PhotoLocator/BitmapOperations/LanczosResizeOperation.cs @@ -128,6 +128,23 @@ public unsafe void Apply(byte* source, int srcOffset, byte* dest, int dstOffset, dest[dstOffset + i * dstSampleDistance] = (byte)RealMath.Clamp(sum, 0, 255); } } + + public unsafe void Apply(float* source, int srcOffset, float* dest, int dstOffset, int dstSampleDistance) + { + var weights = _weights; + for (var i = 0; i < weights.Length; i++) + { + double sum = 0; + int length = weights[i].SourcePixelWeights.Length; + fixed (SourcePixelWeight* sourceWeights = weights[i].SourcePixelWeights) + { + var sourceWeight = sourceWeights; + for (var j = 0; j < length; j++, sourceWeight++) + sum += source[srcOffset + (*sourceWeight).SourceIndex] * (*sourceWeight).SourceWeight; + } + dest[dstOffset + i * dstSampleDistance] = (float)sum; + } + } } class LineResamplerFixedPoint @@ -319,5 +336,46 @@ public byte[] Apply(byte[] pixels, int width, int height, int planes, int pixelS result.Freeze(); return result; } + + public FloatBitmap Apply(FloatBitmap image, int newWidth, int newHeight, CancellationToken ct = default) + { + var planes = image.PlaneCount; + if (image.Width != newWidth) + { + var resampler = new LineResampler(FilterFunc, FilterWindow, image.Width, planes, newWidth); + var target = new FloatBitmap(newWidth, image.Height, planes); + Parallel.For(0, image.Height, y => + { + unsafe + { + fixed (float* src = &image.Elements[y, 0]) + fixed (float* dst = &target.Elements[y, 0]) + for (var p = 0; p < planes; p++) + resampler.Apply(src, p, dst, p, planes); + } + ct.ThrowIfCancellationRequested(); + }); + image = target; + } + if (image.Height != newHeight) + { + var resampler = new LineResampler(FilterFunc, FilterWindow, image.Height, image.Stride, newHeight); + var target = new FloatBitmap(newWidth, newHeight, planes); + Parallel.For(0, image.Width, x => + { + unsafe + { + fixed (float* src = &image.Elements[0, x * planes]) + fixed (float* dst = &target.Elements[0, x * planes]) + for (var p = 0; p < planes; p++) + resampler.Apply(src, p, dst, p, target.Stride); + } + ct.ThrowIfCancellationRequested(); + }); + image = target; + } + return image; + } + } } \ No newline at end of file diff --git a/PhotoLocator/LocalContrastView.xaml b/PhotoLocator/LocalContrastView.xaml index cd990f0..73e047b 100644 --- a/PhotoLocator/LocalContrastView.xaml +++ b/PhotoLocator/LocalContrastView.xaml @@ -85,7 +85,7 @@ - + diff --git a/PhotoLocator/LocalContrastViewModel.cs b/PhotoLocator/LocalContrastViewModel.cs index 256d22e..941c9c9 100644 --- a/PhotoLocator/LocalContrastViewModel.cs +++ b/PhotoLocator/LocalContrastViewModel.cs @@ -135,7 +135,7 @@ public double AstroStretch } public ICommand ResetAstroStretchCommand => new RelayCommand(o => AstroStretch = IsAstroModeEnabled ? AstroStretchOperation.OptimizeStretch(_sourceFloatBitmap) : 0); - public const double DefaultBackgroundRemovalSmooth = 8; + public const double DefaultBackgroundRemovalSmooth = 2; public double BackgroundRemovalSmooth { get; diff --git a/PhotoLocator/Metadata/ExifHandler.cs b/PhotoLocator/Metadata/ExifHandler.cs index fbfc73d..07f4b4c 100644 --- a/PhotoLocator/Metadata/ExifHandler.cs +++ b/PhotoLocator/Metadata/ExifHandler.cs @@ -55,6 +55,8 @@ internal static class ExifHandler public const string DescriptionQuery1 = "/app1/{ushort=0}/{ushort=270}"; public const string DescriptionQuery2 = "/ifd/{ushort=270}"; + public const string DocNameQuery2 = "/ifd/{ushort=269}"; + // North or South Latitude private const string GpsLatitudeRefQuery1 = "/app1/ifd/gps/subifd:{ulong=1}"; // ASCII 2 private const string GpsLatitudeRefQuery2 = "/ifd/{ushort=34853}/{ushort=1}"; // ASCII 2 diff --git a/PhotoLocator/Metadata/MaskBasedNaming.cs b/PhotoLocator/Metadata/MaskBasedNaming.cs index d2badd6..7ee2173 100644 --- a/PhotoLocator/Metadata/MaskBasedNaming.cs +++ b/PhotoLocator/Metadata/MaskBasedNaming.cs @@ -1,9 +1,9 @@ using System; -using System.Linq; +using System.Globalization; using System.IO; +using System.Linq; using System.Text; using System.Windows.Media.Imaging; -using System.Globalization; namespace PhotoLocator.Metadata { @@ -119,10 +119,28 @@ private void AppendMetadataInt(StringBuilder result, int iColon, string tag, str result.Append(Convert.ToInt32(value, CultureInfo.InvariantCulture).ToString("D" + tag[(iColon + 1)..], CultureInfo.CurrentCulture)); } - private void AppendMetadata(StringBuilder result, string query1, string query2) + private void AppendMetadata(StringBuilder result, string query1, string? query2) { var metadata = GetMetadata(); var value = (metadata?.GetQuery(query1) ?? metadata?.GetQuery(query2))?.ToString(); + AppendMetadata(result, value); + } + + private void AppendTitle(StringBuilder result) + { + var metadata = GetMetadata(); + var value = metadata?.GetQuery(ExifHandler.DocNameQuery2) as string; + if (string.IsNullOrEmpty(value)) + value = metadata?.Title; + if (string.IsNullOrEmpty(value)) + value = metadata?.Subject; + if (string.IsNullOrEmpty(value)) + value = metadata?.Comment; + AppendMetadata(result, value); + } + + private static void AppendMetadata(StringBuilder result, string? value) + { if (value is null) return; foreach (var ch in _invalidFileNameChars) @@ -227,10 +245,18 @@ public string GetFileName(string mask) { AppendMetadataInt(result, iColon, tag, ExifHandler.IsoQuery1, ExifHandler.IsoQuery2); } + else if (tag == "title") + { + AppendTitle(result); + } else if (tag == "desc") { AppendMetadata(result, ExifHandler.DescriptionQuery1, ExifHandler.DescriptionQuery2); } + else if (tag[0] == '/') + { + AppendMetadata(result, tag, null); + } else throw new ArgumentException($"Unsupported tag |{tag}|"); i = iEnd; diff --git a/PhotoLocatorTest/BitmapOperations/MaxFramesOperationTest.cs b/PhotoLocatorTest/BitmapOperations/MaxFramesOperationTest.cs index b142761..cb5f11b 100644 --- a/PhotoLocatorTest/BitmapOperations/MaxFramesOperationTest.cs +++ b/PhotoLocatorTest/BitmapOperations/MaxFramesOperationTest.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Linq; using System.Windows.Media.Imaging; namespace PhotoLocator.BitmapOperations; @@ -52,7 +53,12 @@ public void ProcessImage_ShouldUseDarkFrame() var time2 = sw.ElapsedMilliseconds; Debug.WriteLine($"Second frame processing time: {time2}ms"); - //PictureFileFormats.GeneralFileFormatHandler.SaveToFile(op.GetResult8(), "MaxFrame.jpg"); + var result = op.GetResult8(); + //PictureFileFormats.GeneralFileFormatHandler.SaveToFile(result, "MaxFrame.jpg"); + + var pixels = new byte[result.PixelWidth * result.PixelHeight * 3]; + result.CopyPixels(pixels, result.PixelWidth * 3, 0); + Assert.AreEqual(12367134, pixels.Sum(b => b)); Assert.AreEqual(2, op.ProcessedImages); Assert.IsGreaterThan(time2, time1); } From fa201bb224ebc31039a646118ea75884c1e091b5 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Sun, 27 Sep 2026 21:16:08 +0200 Subject: [PATCH 2/9] Support rotation of more formats --- PhotoLocator/ImageTransformCommands.cs | 30 ++++++++++++++++++-------- PhotoLocator/PictureItemViewModel.cs | 2 +- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/PhotoLocator/ImageTransformCommands.cs b/PhotoLocator/ImageTransformCommands.cs index f7dbdd2..ee6a1ac 100644 --- a/PhotoLocator/ImageTransformCommands.cs +++ b/PhotoLocator/ImageTransformCommands.cs @@ -7,6 +7,7 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Input; @@ -43,16 +44,27 @@ public ImageTransformCommands(IMainViewModel mainViewModel) private async Task RotateSelectedAsync(int angle) { - var allSelected = _mainViewModel.GetSelectedItems(true).Where(item => JpegTransformations.IsFileTypeSupported(item.Name)).ToArray(); - if (allSelected.Length == 0) - throw new UserMessageException("Unsupported file format"); + var allSelected = _mainViewModel.GetSelectedItems(true).ToArray(); await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) => { progressCallback(-1); int i = 0; foreach (var item in allSelected) { - await JpegTransformations.RotateAsync(item.FullPath, item.GetProcessedFileName(), angle, ct); + if (JpegTransformations.IsFileTypeSupported(item.Name)) + { + await JpegTransformations.RotateAsync(item.FullPath, item.GetProcessedFileName(), angle, ct); + } + else if (Path.GetExtension(item.Name).ToLowerInvariant() is ".tif" or ".tiff" or ".png" or ".bmp" or ".jxr") + { + item.Orientation = (Rotation)(((int)item.Orientation + angle / 90) % 4); + var (image, metadata) = await LoadImageWithMetadataAsync(item, ct); + var targetFileName = item.GetProcessedFileName(); + GeneralFileFormatHandler.SaveToFile(image, targetFileName, ExifHandler.ResetOrientation(metadata), _mainViewModel.Settings); + item.Orientation = Rotation.Rotate0; + } + else + throw new UserMessageException("Unsupported file format"); item.IsChecked = false; progressCallback((double)(++i) / allSelected.Length); } @@ -72,7 +84,7 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) var targetFileName = selectedItem.GetProcessedFileName(); if (Path.GetExtension(selectedItem.Name).ToLowerInvariant() is ".tif" or ".tiff" or ".png" or ".bmp" or ".jxr") { - var (sourceImage, metadata) = await LoadImageWithMetadataAsync(selectedItem); + var (sourceImage, metadata) = await LoadImageWithMetadataAsync(selectedItem, ct); var cropped = new FloatBitmap(sourceImage, 1).CopyRect( IntMath.Round(cropRectangle.Left), IntMath.Round(cropRectangle.Top), Math.Max(1, IntMath.Round(cropRectangle.Width)), Math.Max(1, IntMath.Round(cropRectangle.Height))); var use16bit = sourceImage.Format == PixelFormats.Gray16 || sourceImage.Format == PixelFormats.Gray32Float || @@ -146,10 +158,10 @@ await Task.Run(() => await SaveProcessedImageAsync(localContrastViewModel, metadata, selectedItem); }, HasFileSelected); - private static async Task<(BitmapSource, BitmapMetadata?)> LoadImageWithMetadataAsync(PictureItemViewModel item) + private static async Task<(BitmapSource, BitmapMetadata?)> LoadImageWithMetadataAsync(PictureItemViewModel item, CancellationToken ct = default) { BitmapMetadata? metadata = null; - var image = await item.LoadPreviewAsync(default, preservePixelFormat: true).ConfigureAwait(false); + var image = await item.LoadPreviewAsync(ct, preservePixelFormat: true).ConfigureAwait(false); try { using var file = File.OpenRead(item.FullPath); @@ -261,7 +273,7 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) } else { - var (image, itemMetadata) = await LoadImageWithMetadataAsync(item); + var (image, itemMetadata) = await LoadImageWithMetadataAsync(item, ct); await Task.Run(() => GeneralFileFormatHandler.SaveToFile(image, targetFileName, ExifHandler.ResetOrientation(itemMetadata), _mainViewModel.Settings), ct); } @@ -311,7 +323,7 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) overwriteAll = true; } - var (image, itemMetadata) = await LoadImageWithMetadataAsync(item); + var (image, itemMetadata) = await LoadImageWithMetadataAsync(item, ct); var newImage = await Task.Run( () => op.Apply(image, int.Max(1, IntMath.Round(image.PixelWidth * newHeight / (double)image.PixelHeight)), newHeight, image.DpiX, image.DpiY, ct) diff --git a/PhotoLocator/PictureItemViewModel.cs b/PhotoLocator/PictureItemViewModel.cs index f04525f..d0b9341 100644 --- a/PhotoLocator/PictureItemViewModel.cs +++ b/PhotoLocator/PictureItemViewModel.cs @@ -192,7 +192,7 @@ public bool CanSaveGeoTag } } - public Rotation Orientation { get; private set; } + public Rotation Orientation { get; internal set; } public async ValueTask LoadThumbnailAndMetadataAsync(CancellationToken ct) { From 3ee22a880789e2543312d7e90c3b3c1fa05d88bb Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Mon, 28 Sep 2026 22:16:32 +0200 Subject: [PATCH 3/9] Fix wait cursor during preview update --- .../BitmapOperations/AstroStretchOperation.cs | 36 +++---------------- PhotoLocator/ImageTransformCommands.cs | 2 +- PhotoLocator/LocalContrastView.xaml | 2 +- PhotoLocator/LocalContrastViewModel.cs | 8 +++-- .../AstroStretchOperationTest.cs | 6 ++-- 5 files changed, 15 insertions(+), 39 deletions(-) diff --git a/PhotoLocator/BitmapOperations/AstroStretchOperation.cs b/PhotoLocator/BitmapOperations/AstroStretchOperation.cs index 3dc8d95..8cd75a5 100644 --- a/PhotoLocator/BitmapOperations/AstroStretchOperation.cs +++ b/PhotoLocator/BitmapOperations/AstroStretchOperation.cs @@ -21,47 +21,19 @@ public override void Apply() } if (BackgroundSmooth > 0) { - //var background = SplitBackgroundChannels ? new FloatBitmap(DstBitmap) : ConvertToGrayscaleOperation.ConvertToGrayscale(DstBitmap); - //IIRSmoothOperation.Apply(background, (float)Math.Exp(BackgroundSmooth)); - //if (BlackPoint > 0) - //{ - // var bp = (float)BlackPoint; - // DstBitmap.ProcessElementWise(background, (p, b) => Math.Max(p - b - bp, 0)); - //} - //else - // DstBitmap.ProcessElementWise(background, (p, b) => Math.Max(p - b, 0)); - - //var background = ConvertToGrayscaleOperation.ConvertToGrayscale(DstBitmap); - //IIRSmoothOperation.Apply(background, (float)Math.Exp(BackgroundSmooth)); - //var mask = ConvertToGrayscaleOperation.ConvertToGrayscale(DstBitmap); - //mask.ProcessElementWise(background, (p, b) => p - b > BlackPoint ? 1 : 0); - //background = SplitBackgroundChannels ? new FloatBitmap(DstBitmap) : ConvertToGrayscaleOperation.ConvertToGrayscale(DstBitmap); - ////mask.SaveToFile("astroMask.jpg"); - //HoleClosingOperation.CloseHolesIteratively(background, mask, 0); - //IIRSmoothOperation.Apply(background, (float)Math.Exp(BackgroundSmooth)); - //DstBitmap.ProcessElementWise(background, (p, b) => Math.Max(p - b, 0)); - const int BackgroundWidth = 128; + var resizeOp = new LanczosResizeOperation { FilterFunc = LanczosResizeOperation.Lanczos2, FilterWindow = 1 }; var backgroundReduced = resizeOp.Apply(DstBitmap, BackgroundWidth, (int)Math.Ceiling((double)BackgroundWidth / DstBitmap.Width * DstBitmap.Height)); - //backgroundReduced.SaveToFile("astroReducedBackground.jpg", FloatBitmap.DefaultMonitorGamma); - - var mask = ConvertToGrayscaleOperation.ConvertToGrayscale(backgroundReduced); - var smoothBackground = new FloatBitmap(mask); + var smoothBackground = ConvertToGrayscaleOperation.ConvertToGrayscale(backgroundReduced); + var mask = new FloatBitmap(smoothBackground); IIRSmoothOperation.Apply(smoothBackground, BackgroundWidth * BackgroundSmooth); mask.ProcessElementWise(smoothBackground, (m, s) => m - s > 0 ? 1 : 0); - - //mask.SaveToFile("astroMask.jpg"); + //DstBitmap.Assign(mask); return; HoleClosingOperation.CloseHolesIteratively(backgroundReduced, mask, 0); - //backgroundReduced.SaveToFile("astroReducedBackgroundClosed.jpg", FloatBitmap.DefaultMonitorGamma); - IIRSmoothOperation.Apply(backgroundReduced, BackgroundWidth * BackgroundSmooth); - var background = resizeOp.Apply(backgroundReduced, DstBitmap.Width, DstBitmap.Height); - //background.SaveToFile("astroBackgroundClosed.jpg", FloatBitmap.DefaultMonitorGamma); - - //IIRSmoothOperation.Apply(background, (float)Math.Exp(BackgroundSmooth)); var bp = (float)BlackPoint; DstBitmap.ProcessElementWise(background, (p, b) => Math.Max(p - b - bp, 0)); diff --git a/PhotoLocator/ImageTransformCommands.cs b/PhotoLocator/ImageTransformCommands.cs index ee6a1ac..d3440f5 100644 --- a/PhotoLocator/ImageTransformCommands.cs +++ b/PhotoLocator/ImageTransformCommands.cs @@ -61,10 +61,10 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) var (image, metadata) = await LoadImageWithMetadataAsync(item, ct); var targetFileName = item.GetProcessedFileName(); GeneralFileFormatHandler.SaveToFile(image, targetFileName, ExifHandler.ResetOrientation(metadata), _mainViewModel.Settings); - item.Orientation = Rotation.Rotate0; } else throw new UserMessageException("Unsupported file format"); + item.Orientation = Rotation.Rotate0; item.IsChecked = false; progressCallback((double)(++i) / allSelected.Length); } diff --git a/PhotoLocator/LocalContrastView.xaml b/PhotoLocator/LocalContrastView.xaml index 73e047b..ef14222 100644 --- a/PhotoLocator/LocalContrastView.xaml +++ b/PhotoLocator/LocalContrastView.xaml @@ -95,7 +95,7 @@ - + diff --git a/PhotoLocator/LocalContrastViewModel.cs b/PhotoLocator/LocalContrastViewModel.cs index 941c9c9..ef894e4 100644 --- a/PhotoLocator/LocalContrastViewModel.cs +++ b/PhotoLocator/LocalContrastViewModel.cs @@ -42,6 +42,7 @@ public LocalContrastViewModel() { try { + Mouse.OverrideCursor = Cursors.AppStarting; await UpdatePreviewAsync(); } catch (Exception ex) @@ -620,7 +621,7 @@ public async Task UpdatePreviewAsync() { _updateTimer.Stop(); await _previewTask; - if (!_previewTask.IsCompleted) // We might have multiple instance of UpdatePreviewAsync running at the same time + if (!_previewTask.IsCompleted) // We might have multiple instances of UpdatePreviewAsync running at the same time { await _previewTask; return; @@ -635,6 +636,8 @@ public async Task UpdatePreviewAsync() _colorToneOperation.SourceChanged(); _firstParamChanged = FirstParamChanged.None; ApplyAstroStretchOperation(); + if (SourceBitmap is null || _updateTimer.IsEnabled) + return; ApplyLaplacianFilterOperation(); if (SourceBitmap is null || _updateTimer.IsEnabled) return; @@ -649,7 +652,8 @@ public async Task UpdatePreviewAsync() (PreviewPictureSource, var histogramTask) = _localContrastOperation.DstBitmap.ToBitmapSourceWithHistogram(srcBitmap.DpiX, srcBitmap.DpiY, FloatBitmap.DefaultMonitorGamma); await histogramTask.ContinueWith(task => SetHistogram(task.Result), TaskScheduler.Current); })); - Mouse.OverrideCursor = null; + if(!_updateTimer.IsEnabled && _previewTask.IsCompleted) + Mouse.OverrideCursor = null; } public void ShowSourceHistogram() diff --git a/PhotoLocatorTest/BitmapOperations/AstroStretchOperationTest.cs b/PhotoLocatorTest/BitmapOperations/AstroStretchOperationTest.cs index 0ed83bb..0bf8eb8 100644 --- a/PhotoLocatorTest/BitmapOperations/AstroStretchOperationTest.cs +++ b/PhotoLocatorTest/BitmapOperations/AstroStretchOperationTest.cs @@ -21,6 +21,7 @@ public void Apply_AstroStretchOperation() { SrcBitmap = sourceFloat, DstBitmap = new FloatBitmap(), + Stretch = AstroStretchOperation.OptimizeStretch(sourceFloat), }; op.Apply(); Console.WriteLine(sw.ElapsedMilliseconds); @@ -28,11 +29,10 @@ public void Apply_AstroStretchOperation() sw.Restart(); var result = op.DstBitmap.ToBitmapSource(source.DpiX, source.DpiY, FloatBitmap.DefaultMonitorGamma); Console.WriteLine(sw.ElapsedMilliseconds); - - Assert.AreEqual(0.0026545193517195226, op.DstBitmap.Mean(), 1e-5); #if DEBUG - GeneralFileFormatHandler.SaveToFile(result, "astroStretch.png"); + GeneralFileFormatHandler.SaveToFile(result, "astroStretch.jpg"); #endif + Assert.AreEqual(0.04771542762189313, op.DstBitmap.Mean(), 1e-5); } } } From 2a0601a8201149ebc93d761a0c5b581b1c1d1f1e Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Mon, 28 Sep 2026 22:49:35 +0200 Subject: [PATCH 4/9] Support for resizing 16 bit images --- PhotoLocator/BitmapOperations/FloatBitmap.cs | 2 +- PhotoLocator/ImageTransformCommands.cs | 33 +++++++++++--------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/PhotoLocator/BitmapOperations/FloatBitmap.cs b/PhotoLocator/BitmapOperations/FloatBitmap.cs index 1f7df25..07842e5 100644 --- a/PhotoLocator/BitmapOperations/FloatBitmap.cs +++ b/PhotoLocator/BitmapOperations/FloatBitmap.cs @@ -287,7 +287,7 @@ public BitmapSource ToBitmapSource16(double dpiX, double dpiY, double gamma) }); } - PixelFormat pixelFormat16 = PlaneCount switch + var pixelFormat16 = PlaneCount switch { 1 => PixelFormats.Gray16, 3 => PixelFormats.Rgb48, diff --git a/PhotoLocator/ImageTransformCommands.cs b/PhotoLocator/ImageTransformCommands.cs index d3440f5..97103ad 100644 --- a/PhotoLocator/ImageTransformCommands.cs +++ b/PhotoLocator/ImageTransformCommands.cs @@ -36,10 +36,10 @@ public ImageTransformCommands(IMainViewModel mainViewModel) public ICommand Rotate180Command => new RelayCommand(async o => await RotateSelectedAsync(180), HasFileSelected); public ICommand Rotate0Command => new RelayCommand(async o => - { + { if (MessageBox.Show("This will reset any rotation EXIF data from the selected images. Continue?", "Reset rotation tag", MessageBoxButton.OKCancel, MessageBoxImage.Question) != MessageBoxResult.OK) return; - await RotateSelectedAsync(0); + await RotateSelectedAsync(0); }, HasFileSelected); private async Task RotateSelectedAsync(int angle) @@ -81,13 +81,13 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) progressCallback(-1); await using var pause = _mainViewModel.PauseFileSystemWatcher(); var sourceFileName = selectedItem.FullPath; - var targetFileName = selectedItem.GetProcessedFileName(); + var targetFileName = selectedItem.GetProcessedFileName(); if (Path.GetExtension(selectedItem.Name).ToLowerInvariant() is ".tif" or ".tiff" or ".png" or ".bmp" or ".jxr") { var (sourceImage, metadata) = await LoadImageWithMetadataAsync(selectedItem, ct); var cropped = new FloatBitmap(sourceImage, 1).CopyRect( IntMath.Round(cropRectangle.Left), IntMath.Round(cropRectangle.Top), Math.Max(1, IntMath.Round(cropRectangle.Width)), Math.Max(1, IntMath.Round(cropRectangle.Height))); - var use16bit = sourceImage.Format == PixelFormats.Gray16 || sourceImage.Format == PixelFormats.Gray32Float || + var use16bit = sourceImage.Format == PixelFormats.Gray16 || sourceImage.Format == PixelFormats.Gray32Float || sourceImage.Format == PixelFormats.Rgb48 || sourceImage.Format.BitsPerPixel == 96; await Task.Run(() => GeneralFileFormatHandler.SaveToFile( use16bit ? cropped.ToBitmapSource16(sourceImage.DpiX, sourceImage.DpiY, 1) : cropped.ToBitmapSource(sourceImage.DpiX, sourceImage.DpiY, 1), @@ -127,9 +127,9 @@ await Task.Run(() => using (var cursor = new MouseCursorOverride()) { (var image, metadata) = await Task.Run(() => LoadImageWithMetadataAsync(selectedItem)); - localContrastViewModel = new LocalContrastViewModel() - { - IsAstroModeEnabled = o as string == AstroCommandParameter, + localContrastViewModel = new LocalContrastViewModel() + { + IsAstroModeEnabled = o as string == AstroCommandParameter, SourceBitmap = image, FileName = selectedItem.FullPath, }; @@ -207,7 +207,7 @@ await Task.Run(() => if (sameDir) await _mainViewModel.AddOrUpdateItemAsync(dlg.FileName, false, false); } - } + } private async Task BatchProcessLocalContrastAsync(LocalContrastViewModel localContrastViewModel, BitmapMetadata? metadata, PictureItemViewModel[] allSelected, PictureItemViewModel selectedItem) { @@ -291,7 +291,7 @@ await Task.Run(() => GeneralFileFormatHandler.SaveToFile(image, targetFileName, if (allSelected.Length == 0) return; int newHeight = 0; - if (TextInputWindow.Show("New image height (width will be adjusted to keep ratio):", str => int.TryParse(str, CultureInfo.CurrentCulture, out newHeight) && newHeight > 0, + if (TextInputWindow.Show("New image height (width will be adjusted to keep ratio):", str => int.TryParse(str, CultureInfo.CurrentCulture, out newHeight) && newHeight > 0, "Resize", _resizeTargetHeight.ToString(CultureInfo.CurrentCulture)) is null) return; _resizeTargetHeight = newHeight; @@ -308,8 +308,7 @@ await Task.Run(() => GeneralFileFormatHandler.SaveToFile(image, targetFileName, await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) => { var overwriteAll = targetIsSourceDir; - var op = new LanczosResizeOperation(); - op.FilterFunc = LanczosResizeOperation.Lanczos2; + var op = new LanczosResizeOperation { FilterFunc = LanczosResizeOperation.Lanczos2, FilterWindow = 2 }; int i = 0; Task? previousSaveTask = null; foreach (var item in allSelected) @@ -325,9 +324,15 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) var (image, itemMetadata) = await LoadImageWithMetadataAsync(item, ct); - var newImage = await Task.Run( - () => op.Apply(image, int.Max(1, IntMath.Round(image.PixelWidth * newHeight / (double)image.PixelHeight)), newHeight, image.DpiX, image.DpiY, ct) - ?? throw new UserMessageException("Unsupported pixel format " + image.Format), ct); + var newImage = await Task.Run(() => + { + var newWidth = int.Max(1, IntMath.Round(image.PixelWidth * newHeight / (double)image.PixelHeight)); + var sourceBitsPerChannel = image.Format.BitsPerPixel / image.Format.Masks.Count; + var gamma = sourceBitsPerChannel == 8 ? FloatBitmap.DefaultMonitorGamma : 1; + var floatImage = new FloatBitmap(image, gamma); + floatImage = op.Apply(floatImage, newWidth, newHeight, ct); + return sourceBitsPerChannel == 8 ? floatImage.ToBitmapSource(image.DpiX, image.DpiY, gamma) : floatImage.ToBitmapSource16(image.DpiX, image.DpiY, gamma); + }, ct); await (previousSaveTask ?? Task.CompletedTask); previousSaveTask = Task.Run(() => From fc060c14ea6540292869234de09c36bb589b20ea Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Mon, 28 Sep 2026 23:16:40 +0200 Subject: [PATCH 5/9] Fix 32 bit float resize --- PhotoLocator/ImageTransformCommands.cs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/PhotoLocator/ImageTransformCommands.cs b/PhotoLocator/ImageTransformCommands.cs index 97103ad..d64cb65 100644 --- a/PhotoLocator/ImageTransformCommands.cs +++ b/PhotoLocator/ImageTransformCommands.cs @@ -87,11 +87,10 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) var (sourceImage, metadata) = await LoadImageWithMetadataAsync(selectedItem, ct); var cropped = new FloatBitmap(sourceImage, 1).CopyRect( IntMath.Round(cropRectangle.Left), IntMath.Round(cropRectangle.Top), Math.Max(1, IntMath.Round(cropRectangle.Width)), Math.Max(1, IntMath.Round(cropRectangle.Height))); - var use16bit = sourceImage.Format == PixelFormats.Gray16 || sourceImage.Format == PixelFormats.Gray32Float || - sourceImage.Format == PixelFormats.Rgb48 || sourceImage.Format.BitsPerPixel == 96; + var sourceBitsPerChannel = sourceImage.Format.BitsPerPixel / sourceImage.Format.Masks.Count; await Task.Run(() => GeneralFileFormatHandler.SaveToFile( - use16bit ? cropped.ToBitmapSource16(sourceImage.DpiX, sourceImage.DpiY, 1) : cropped.ToBitmapSource(sourceImage.DpiX, sourceImage.DpiY, 1), - targetFileName, sourceImage.Format.BitsPerPixel == 96 ? null : metadata, _mainViewModel.Settings), ct); + sourceBitsPerChannel > 8 ? cropped.ToBitmapSource16(sourceImage.DpiX, sourceImage.DpiY, 1) : cropped.ToBitmapSource(sourceImage.DpiX, sourceImage.DpiY, 1), + targetFileName, sourceImage.Format.BitsPerPixel == 96 ? null : ExifHandler.ResetOrientation(metadata), _mainViewModel.Settings), ct); } else { @@ -328,16 +327,16 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) { var newWidth = int.Max(1, IntMath.Round(image.PixelWidth * newHeight / (double)image.PixelHeight)); var sourceBitsPerChannel = image.Format.BitsPerPixel / image.Format.Masks.Count; - var gamma = sourceBitsPerChannel == 8 ? FloatBitmap.DefaultMonitorGamma : 1; + var gamma = sourceBitsPerChannel > 8 ? 1 : FloatBitmap.DefaultMonitorGamma; var floatImage = new FloatBitmap(image, gamma); floatImage = op.Apply(floatImage, newWidth, newHeight, ct); - return sourceBitsPerChannel == 8 ? floatImage.ToBitmapSource(image.DpiX, image.DpiY, gamma) : floatImage.ToBitmapSource16(image.DpiX, image.DpiY, gamma); + return sourceBitsPerChannel > 8 ? floatImage.ToBitmapSource16(image.DpiX, image.DpiY, gamma) : floatImage.ToBitmapSource(image.DpiX, image.DpiY, gamma); }, ct); await (previousSaveTask ?? Task.CompletedTask); previousSaveTask = Task.Run(() => { - GeneralFileFormatHandler.SaveToFile(newImage, targetFileName, ExifHandler.ResetOrientation(itemMetadata), _mainViewModel.Settings); + GeneralFileFormatHandler.SaveToFile(newImage, targetFileName, image.Format.BitsPerPixel == 96 ? null : ExifHandler.ResetOrientation(itemMetadata), _mainViewModel.Settings); item.IsChecked = false; }, ct); progressCallback((double)(i++) / allSelected.Length); From 904761b4443070569aeee9f44e7c65668e9517c1 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Tue, 29 Sep 2026 22:32:31 +0200 Subject: [PATCH 6/9] Improve float metadata handling --- PhotoLocator/ImageTransformCommands.cs | 14 +++++++------- PhotoLocator/LocalContrastView.xaml | 2 +- PhotoLocator/LocalContrastViewModel.cs | 2 +- PhotoLocator/MainWindow.xaml | 4 ++-- PhotoLocator/Metadata/ExifHandler.cs | 23 +++++++++++++++++++++++ 5 files changed, 34 insertions(+), 11 deletions(-) diff --git a/PhotoLocator/ImageTransformCommands.cs b/PhotoLocator/ImageTransformCommands.cs index d64cb65..1c9ed6a 100644 --- a/PhotoLocator/ImageTransformCommands.cs +++ b/PhotoLocator/ImageTransformCommands.cs @@ -90,7 +90,7 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) var sourceBitsPerChannel = sourceImage.Format.BitsPerPixel / sourceImage.Format.Masks.Count; await Task.Run(() => GeneralFileFormatHandler.SaveToFile( sourceBitsPerChannel > 8 ? cropped.ToBitmapSource16(sourceImage.DpiX, sourceImage.DpiY, 1) : cropped.ToBitmapSource(sourceImage.DpiX, sourceImage.DpiY, 1), - targetFileName, sourceImage.Format.BitsPerPixel == 96 ? null : ExifHandler.ResetOrientation(metadata), _mainViewModel.Settings), ct); + targetFileName, ExifHandler.PrepareMetadataForProcessedImage(sourceImage.Format, metadata), _mainViewModel.Settings), ct); } else { @@ -149,6 +149,7 @@ await Task.Run(() => } localContrastViewModel.SaveLastUsedValues(); + metadata = ExifHandler.PrepareMetadataForProcessedImage(localContrastViewModel.SourceBitmap.Format, metadata); if (allSelected.Length > 1 && MessageBox.Show($"Apply operation to all {allSelected.Length} selected files and save to JPG?", "Batch process", MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes) @@ -201,7 +202,7 @@ await Task.Run(() => var resultImage = localContrastViewModel.GetResultImage( localContrastViewModel.SourceBitmap?.Format != PixelFormats.Cmyk32 && GeneralFileFormatHandler.ShouldProduce16bitOutputForFormat(dlg.FileName, _mainViewModel.Settings)); - GeneralFileFormatHandler.SaveToFile(resultImage, dlg.FileName, ExifHandler.ResetOrientation(metadata), _mainViewModel.Settings); + GeneralFileFormatHandler.SaveToFile(resultImage, dlg.FileName, metadata, _mainViewModel.Settings); }); if (sameDir) await _mainViewModel.AddOrUpdateItemAsync(dlg.FileName, false, false); @@ -218,15 +219,14 @@ await _mainViewModel.RunProcessWithProgressBarAsync((progressCallback, ct) => Ta var targetFileName = Path.ChangeExtension(item.GetProcessedFileName(), "jpg"); if (item == selectedItem) { - GeneralFileFormatHandler.SaveToFile(localContrastViewModel.PreviewPictureSource!, targetFileName, - ExifHandler.ResetOrientation(metadata), _mainViewModel.Settings); + GeneralFileFormatHandler.SaveToFile(localContrastViewModel.PreviewPictureSource!, targetFileName, metadata, _mainViewModel.Settings); } else { var (image, itemMetadata) = await LoadImageWithMetadataAsync(item); image = localContrastViewModel.ApplyOperations(image); - GeneralFileFormatHandler.SaveToFile(image, targetFileName, - ExifHandler.ResetOrientation(itemMetadata), _mainViewModel.Settings); + GeneralFileFormatHandler.SaveToFile(image, targetFileName, + ExifHandler.PrepareMetadataForProcessedImage(image.Format, itemMetadata), _mainViewModel.Settings); } item.IsChecked = false; progressCallback((double)(++i) / allSelected.Length); @@ -336,7 +336,7 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) await (previousSaveTask ?? Task.CompletedTask); previousSaveTask = Task.Run(() => { - GeneralFileFormatHandler.SaveToFile(newImage, targetFileName, image.Format.BitsPerPixel == 96 ? null : ExifHandler.ResetOrientation(itemMetadata), _mainViewModel.Settings); + GeneralFileFormatHandler.SaveToFile(newImage, targetFileName, ExifHandler.PrepareMetadataForProcessedImage(image.Format, itemMetadata), _mainViewModel.Settings); item.IsChecked = false; }, ct); progressCallback((double)(i++) / allSelected.Length); diff --git a/PhotoLocator/LocalContrastView.xaml b/PhotoLocator/LocalContrastView.xaml index ef14222..917597f 100644 --- a/PhotoLocator/LocalContrastView.xaml +++ b/PhotoLocator/LocalContrastView.xaml @@ -85,7 +85,7 @@ - + diff --git a/PhotoLocator/LocalContrastViewModel.cs b/PhotoLocator/LocalContrastViewModel.cs index ef894e4..8ff97ee 100644 --- a/PhotoLocator/LocalContrastViewModel.cs +++ b/PhotoLocator/LocalContrastViewModel.cs @@ -136,7 +136,7 @@ public double AstroStretch } public ICommand ResetAstroStretchCommand => new RelayCommand(o => AstroStretch = IsAstroModeEnabled ? AstroStretchOperation.OptimizeStretch(_sourceFloatBitmap) : 0); - public const double DefaultBackgroundRemovalSmooth = 2; + public const double DefaultBackgroundRemovalSmooth = 1; public double BackgroundRemovalSmooth { get; diff --git a/PhotoLocator/MainWindow.xaml b/PhotoLocator/MainWindow.xaml index 009d73e..0864c92 100644 --- a/PhotoLocator/MainWindow.xaml +++ b/PhotoLocator/MainWindow.xaml @@ -274,7 +274,7 @@ - + @@ -404,7 +404,7 @@ - + diff --git a/PhotoLocator/Metadata/ExifHandler.cs b/PhotoLocator/Metadata/ExifHandler.cs index 07f4b4c..ba39e88 100644 --- a/PhotoLocator/Metadata/ExifHandler.cs +++ b/PhotoLocator/Metadata/ExifHandler.cs @@ -12,6 +12,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Windows.Media; using System.Windows.Media.Imaging; namespace PhotoLocator.Metadata @@ -290,7 +291,29 @@ void ResetOrientationValue(string orientationQuery) ResetOrientationValue(OrientationQuery1); ResetOrientationValue(OrientationQuery2); return metadata; + } + [return: NotNullIfNotNull(nameof(metadata))] + public static BitmapMetadata? PrepareMetadataForProcessedImage(PixelFormat sourceFormat, BitmapMetadata? metadata) + { + static BitmapMetadata RemoveValue(BitmapMetadata metadata, string query) + { + if (metadata.GetQuery(query) is not null) + { + if (metadata.IsFrozen) + metadata = metadata.Clone(); + metadata.RemoveQuery(query); + } + return metadata; + } + + if ( sourceFormat.BitsPerPixel == 96 && metadata is not null ) + { + metadata = RemoveValue(metadata, "/ifd/{ushort=339}"); // Format + metadata = RemoveValue(metadata, "/ifd/{ushort=340}"); // Min value + metadata = RemoveValue(metadata, "/ifd/{ushort=341}"); // Max value + } + return ResetOrientation(metadata); } public static MemoryStream SetJpegMetadata(Stream source, BitmapMetadata metadata) From f0963537ae1eff0049ece6f0e8619bdcd7ec085a Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Thu, 1 Oct 2026 20:57:50 +0200 Subject: [PATCH 7/9] Apply batched suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- PhotoLocator/ImageTransformCommands.cs | 17 +++++++++++++---- PhotoLocator/Metadata/MaskBasedNaming.cs | 4 ++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/PhotoLocator/ImageTransformCommands.cs b/PhotoLocator/ImageTransformCommands.cs index 1c9ed6a..22fbaf0 100644 --- a/PhotoLocator/ImageTransformCommands.cs +++ b/PhotoLocator/ImageTransformCommands.cs @@ -57,10 +57,19 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) } else if (Path.GetExtension(item.Name).ToLowerInvariant() is ".tif" or ".tiff" or ".png" or ".bmp" or ".jxr") { - item.Orientation = (Rotation)(((int)item.Orientation + angle / 90) % 4); - var (image, metadata) = await LoadImageWithMetadataAsync(item, ct); - var targetFileName = item.GetProcessedFileName(); - GeneralFileFormatHandler.SaveToFile(image, targetFileName, ExifHandler.ResetOrientation(metadata), _mainViewModel.Settings); + var originalOrientation = item.Orientation; + try + { + item.Orientation = (Rotation)(((int)item.Orientation + angle / 90) % 4); + var (image, metadata) = await LoadImageWithMetadataAsync(item, ct); + var targetFileName = item.GetProcessedFileName(); + GeneralFileFormatHandler.SaveToFile(image, targetFileName, ExifHandler.ResetOrientation(metadata), _mainViewModel.Settings); + } + catch + { + item.Orientation = originalOrientation; + throw; + } } else throw new UserMessageException("Unsupported file format"); diff --git a/PhotoLocator/Metadata/MaskBasedNaming.cs b/PhotoLocator/Metadata/MaskBasedNaming.cs index 7ee2173..d4e0a23 100644 --- a/PhotoLocator/Metadata/MaskBasedNaming.cs +++ b/PhotoLocator/Metadata/MaskBasedNaming.cs @@ -122,7 +122,7 @@ private void AppendMetadataInt(StringBuilder result, int iColon, string tag, str private void AppendMetadata(StringBuilder result, string query1, string? query2) { var metadata = GetMetadata(); - var value = (metadata?.GetQuery(query1) ?? metadata?.GetQuery(query2))?.ToString(); + var value = (metadata?.GetQuery(query1) ?? (query2 is null ? null : metadata?.GetQuery(query2)))?.ToString(); AppendMetadata(result, value); } @@ -253,7 +253,7 @@ public string GetFileName(string mask) { AppendMetadata(result, ExifHandler.DescriptionQuery1, ExifHandler.DescriptionQuery2); } - else if (tag[0] == '/') + else if (tag.StartsWith('/')) { AppendMetadata(result, tag, null); } From 882f426d7dd4fe96929f0a4e0e4795c192e749b2 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Thu, 1 Oct 2026 22:27:35 +0200 Subject: [PATCH 8/9] Add tests --- .github/copilot-instructions.md | 2 + PhotoLocator.sln | 5 +- .../BitmapOperations/AstroStretchOperation.cs | 2 +- PhotoLocator/IMainViewModel.cs | 2 + PhotoLocator/ImageTransformCommands.cs | 35 ++++--- PhotoLocator/LocalContrastViewModel.cs | 2 +- .../ImageTransformCommandsTest.cs | 97 +++++++++++++++++++ 7 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 PhotoLocatorTest/ImageTransformCommandsTest.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..5f0d64b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,2 @@ +* Ask questions about the prompt to clarify any uncertainties. +* Ensure that new code compiles without warnings or errors. diff --git a/PhotoLocator.sln b/PhotoLocator.sln index 74923fc..4eb72ed 100644 --- a/PhotoLocator.sln +++ b/PhotoLocator.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32210.238 +# Visual Studio Version 18 +VisualStudioVersion = 18.10.12224.181 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PhotoLocator", "PhotoLocator\PhotoLocator.csproj", "{D9DD58A6-DE57-4DDB-92A8-2AF08B1D8309}" EndProject @@ -11,6 +11,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig .gitignore = .gitignore .sonarcloud.properties = .sonarcloud.properties + .github\copilot-instructions.md = .github\copilot-instructions.md Directory.Build.props = Directory.Build.props .github\workflows\dotnet.yml = .github\workflows\dotnet.yml LICENSE = LICENSE diff --git a/PhotoLocator/BitmapOperations/AstroStretchOperation.cs b/PhotoLocator/BitmapOperations/AstroStretchOperation.cs index 8cd75a5..d4e530e 100644 --- a/PhotoLocator/BitmapOperations/AstroStretchOperation.cs +++ b/PhotoLocator/BitmapOperations/AstroStretchOperation.cs @@ -38,7 +38,7 @@ public override void Apply() var bp = (float)BlackPoint; DstBitmap.ProcessElementWise(background, (p, b) => Math.Max(p - b - bp, 0)); } - else if (BlackPoint > 0) + else if (BlackPoint != 0) { var bp = (float)BlackPoint; DstBitmap.ProcessElementWise(p => Math.Max(p - bp, 0)); diff --git a/PhotoLocator/IMainViewModel.cs b/PhotoLocator/IMainViewModel.cs index ceba619..5bdaa1d 100644 --- a/PhotoLocator/IMainViewModel.cs +++ b/PhotoLocator/IMainViewModel.cs @@ -25,5 +25,7 @@ public interface IMainViewModel Task RunProcessWithProgressBarAsync(Func, CancellationToken, Task> body, string text, PictureItemViewModel? focusItem = null); IAsyncDisposable PauseFileSystemWatcher(); + + Task WaitForPicturesLoadedAsync(); } } \ No newline at end of file diff --git a/PhotoLocator/ImageTransformCommands.cs b/PhotoLocator/ImageTransformCommands.cs index 22fbaf0..d5b1cc9 100644 --- a/PhotoLocator/ImageTransformCommands.cs +++ b/PhotoLocator/ImageTransformCommands.cs @@ -44,6 +44,7 @@ public ImageTransformCommands(IMainViewModel mainViewModel) private async Task RotateSelectedAsync(int angle) { + await _mainViewModel.WaitForPicturesLoadedAsync(); var allSelected = _mainViewModel.GetSelectedItems(true).ToArray(); await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) => { @@ -96,7 +97,7 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) var (sourceImage, metadata) = await LoadImageWithMetadataAsync(selectedItem, ct); var cropped = new FloatBitmap(sourceImage, 1).CopyRect( IntMath.Round(cropRectangle.Left), IntMath.Round(cropRectangle.Top), Math.Max(1, IntMath.Round(cropRectangle.Width)), Math.Max(1, IntMath.Round(cropRectangle.Height))); - var sourceBitsPerChannel = sourceImage.Format.BitsPerPixel / sourceImage.Format.Masks.Count; + var sourceBitsPerChannel = sourceImage.Format.BitsPerPixel / int.Max(1, sourceImage.Format.Masks.Count); await Task.Run(() => GeneralFileFormatHandler.SaveToFile( sourceBitsPerChannel > 8 ? cropped.ToBitmapSource16(sourceImage.DpiX, sourceImage.DpiY, 1) : cropped.ToBitmapSource(sourceImage.DpiX, sourceImage.DpiY, 1), targetFileName, ExifHandler.PrepareMetadataForProcessedImage(sourceImage.Format, metadata), _mainViewModel.Settings), ct); @@ -298,23 +299,27 @@ await Task.Run(() => GeneralFileFormatHandler.SaveToFile(image, targetFileName, var allSelected = _mainViewModel.GetSelectedItems(true).ToArray(); if (allSelected.Length == 0) return; - int newHeight = 0; - if (TextInputWindow.Show("New image height (width will be adjusted to keep ratio):", str => int.TryParse(str, CultureInfo.CurrentCulture, out newHeight) && newHeight > 0, - "Resize", _resizeTargetHeight.ToString(CultureInfo.CurrentCulture)) is null) - return; - _resizeTargetHeight = newHeight; - var browser = new System.Windows.Forms.FolderBrowserDialog(); - browser.InitialDirectory = Path.GetDirectoryName(allSelected[0].FullPath)!; - browser.Description = $"Select target folder for resized images"; - browser.UseDescriptionForTitle = true; - if (browser.ShowDialog() != System.Windows.Forms.DialogResult.OK) - return; - var targetDir = browser.SelectedPath; - var targetIsSourceDir = string.Equals(targetDir, browser.InitialDirectory, StringComparison.OrdinalIgnoreCase); + if (o is not (string targetDir, int newHeight)) + { + newHeight = 0; + if (TextInputWindow.Show("New image height (width will be adjusted to keep ratio):", str => int.TryParse(str, CultureInfo.CurrentCulture, out newHeight) && newHeight > 0, + "Resize", _resizeTargetHeight.ToString(CultureInfo.CurrentCulture)) is null) + return; + _resizeTargetHeight = newHeight; + + var browser = new System.Windows.Forms.FolderBrowserDialog(); + browser.InitialDirectory = Path.GetDirectoryName(allSelected[0].FullPath)!; + browser.Description = $"Select target folder for resized images"; + browser.UseDescriptionForTitle = true; + if (browser.ShowDialog() != System.Windows.Forms.DialogResult.OK) + return; + targetDir = browser.SelectedPath; + } await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) => { + var targetIsSourceDir = string.Equals(targetDir, Path.GetDirectoryName(allSelected[0].FullPath), StringComparison.OrdinalIgnoreCase); var overwriteAll = targetIsSourceDir; var op = new LanczosResizeOperation { FilterFunc = LanczosResizeOperation.Lanczos2, FilterWindow = 2 }; int i = 0; @@ -335,7 +340,7 @@ await _mainViewModel.RunProcessWithProgressBarAsync(async (progressCallback, ct) var newImage = await Task.Run(() => { var newWidth = int.Max(1, IntMath.Round(image.PixelWidth * newHeight / (double)image.PixelHeight)); - var sourceBitsPerChannel = image.Format.BitsPerPixel / image.Format.Masks.Count; + var sourceBitsPerChannel = image.Format.BitsPerPixel / int.Max(1, image.Format.Masks.Count); var gamma = sourceBitsPerChannel > 8 ? 1 : FloatBitmap.DefaultMonitorGamma; var floatImage = new FloatBitmap(image, gamma); floatImage = op.Apply(floatImage, newWidth, newHeight, ct); diff --git a/PhotoLocator/LocalContrastViewModel.cs b/PhotoLocator/LocalContrastViewModel.cs index 8ff97ee..872b7fb 100644 --- a/PhotoLocator/LocalContrastViewModel.cs +++ b/PhotoLocator/LocalContrastViewModel.cs @@ -567,7 +567,7 @@ private void StartUpdateTimer(FirstParamChanged firstParamChanged) void ApplyAstroStretchOperation() { - if (IsAstroModeEnabled && (AstroStretch > 0 || BackgroundRemovalSmooth > 0 || BlackPoint > 0)) + if (IsAstroModeEnabled && (AstroStretch > 0 || BackgroundRemovalSmooth > 0 || BlackPoint != 0)) { var astroStretch = new AstroStretchOperation() { diff --git a/PhotoLocatorTest/ImageTransformCommandsTest.cs b/PhotoLocatorTest/ImageTransformCommandsTest.cs new file mode 100644 index 0000000..e41bbb3 --- /dev/null +++ b/PhotoLocatorTest/ImageTransformCommandsTest.cs @@ -0,0 +1,97 @@ +using Moq; +using PhotoLocator.Settings; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +namespace PhotoLocator; + +[TestClass] +public class ImageTransformCommandsTest +{ + Task? _processTask = null; + + [TestMethod] + public async Task RotateSelectedAsync_ShouldProduce48BppFile() + { + var sourcePath = Path.Combine(AppContext.BaseDirectory, "TestData", "RGB48.tif"); + + var settings = new ObservableSettings { SavedFilePostfix = "_rotated" }; + var item = new PictureItemViewModel(sourcePath, false, null, settings); + await item.LoadThumbnailAndMetadataAsync(default); + var mainViewModel = SetupMainViewModelMock(settings, item); + var outputPath = item.GetProcessedFileName(); + File.Delete(outputPath); + + var commands = new ImageTransformCommands(mainViewModel.Object); + commands.RotateRightCommand.Execute(null); + await (_processTask ?? throw new InvalidOperationException("The rotate process was not started")); + + using var stream = File.OpenRead(outputPath); + var frame = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad).Frames[0]; + Assert.AreEqual(48, frame.PixelWidth); + Assert.AreEqual(85, frame.PixelHeight); + Assert.AreEqual(PixelFormats.Rgb48, frame.Format); + } + + [TestMethod] + public async Task CropSelectedItemAsync_ShouldProduce48BppFile() + { + var sourcePath = Path.Combine(AppContext.BaseDirectory, "TestData", "RGB48.tif"); + + var settings = new ObservableSettings { SavedFilePostfix = "_cropped" }; + var item = new PictureItemViewModel(sourcePath, false, null, settings); + var sourceImage = await item.LoadPreviewAsync(default) ?? throw new InvalidOperationException("Unable to load source image"); + var mainViewModel = SetupMainViewModelMock(settings, item); + var outputPath = item.GetProcessedFileName(); + File.Delete(outputPath); + + var commands = new ImageTransformCommands(mainViewModel.Object); + await commands.CropSelectedItemAsync(sourceImage, new Rect(4, 4, 24, 20)); + + using var stream = File.OpenRead(outputPath); + var frame = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad).Frames[0]; + Assert.AreEqual(24, frame.PixelWidth); + Assert.AreEqual(20, frame.PixelHeight); + Assert.AreEqual(PixelFormats.Rgb48, frame.Format); + } + + [TestMethod] + public async Task ResizeCommand_ShouldProduce48BppFile() + { + var sourcePath = Path.Combine(AppContext.BaseDirectory, "TestData", "RGB48.tif"); + + var settings = new ObservableSettings { SavedFilePostfix = "_resized" }; + var item = new PictureItemViewModel(sourcePath, false, null, settings); + var mainViewModel = SetupMainViewModelMock(settings, item); + var outputPath = item.GetProcessedFileName(); + File.Delete(outputPath); + + var commands = new ImageTransformCommands(mainViewModel.Object); + commands.ResizeCommand.Execute((Path.GetDirectoryName(sourcePath), 24)); + await (_processTask ?? throw new InvalidOperationException("The resize process was not started")); + + using var stream = File.OpenRead(outputPath); + var frame = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad).Frames[0]; + Assert.AreEqual(42, frame.PixelWidth); + Assert.AreEqual(24, frame.PixelHeight); + Assert.AreEqual(PixelFormats.Rgb48, frame.Format); + } + + private Mock SetupMainViewModelMock(ObservableSettings settings, PictureItemViewModel item) + { + var mainViewModel = new Mock(); + mainViewModel.Setup(m => m.Settings).Returns(settings); + mainViewModel.Setup(m => m.SelectedItem).Returns(item); + mainViewModel.Setup(m => m.GetSelectedItems(true)).Returns([item]); + mainViewModel.Setup(m => m.RunProcessWithProgressBarAsync( + It.IsAny, CancellationToken, Task>>(), + It.IsAny(), + It.IsAny())) + .Returns((Func, CancellationToken, Task> body, string text, PictureItemViewModel? focusItem) => + { + return _processTask = body(_ => { }, CancellationToken.None); + }); + return mainViewModel; + } +} From fe1c1e43897a18795f12b9b69e206d8c083870b1 Mon Sep 17 00:00:00 2001 From: Michael Vinther Date: Fri, 2 Oct 2026 21:22:25 +0200 Subject: [PATCH 9/9] Optimize hole closer --- PhotoLocator/BitmapOperations/AstroStretchOperation.cs | 10 +++++----- PhotoLocator/BitmapOperations/HoleClosingOperation.cs | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/PhotoLocator/BitmapOperations/AstroStretchOperation.cs b/PhotoLocator/BitmapOperations/AstroStretchOperation.cs index d4e530e..6c698a2 100644 --- a/PhotoLocator/BitmapOperations/AstroStretchOperation.cs +++ b/PhotoLocator/BitmapOperations/AstroStretchOperation.cs @@ -25,11 +25,11 @@ public override void Apply() var resizeOp = new LanczosResizeOperation { FilterFunc = LanczosResizeOperation.Lanczos2, FilterWindow = 1 }; var backgroundReduced = resizeOp.Apply(DstBitmap, BackgroundWidth, (int)Math.Ceiling((double)BackgroundWidth / DstBitmap.Width * DstBitmap.Height)); - var smoothBackground = ConvertToGrayscaleOperation.ConvertToGrayscale(backgroundReduced); - var mask = new FloatBitmap(smoothBackground); - IIRSmoothOperation.Apply(smoothBackground, BackgroundWidth * BackgroundSmooth); - mask.ProcessElementWise(smoothBackground, (m, s) => m - s > 0 ? 1 : 0); - //DstBitmap.Assign(mask); return; + var grayBackground = ConvertToGrayscaleOperation.ConvertToGrayscale(backgroundReduced); + var mask = new FloatBitmap(grayBackground); + IIRSmoothOperation.Apply(grayBackground, BackgroundWidth * BackgroundSmooth); + mask.ProcessElementWise(grayBackground, (m, g) => m - g); + //DstBitmap.Assign(mask); DstBitmap.ProcessElementWise(p => p > 0 ? 1 : 0); return; HoleClosingOperation.CloseHolesIteratively(backgroundReduced, mask, 0); IIRSmoothOperation.Apply(backgroundReduced, BackgroundWidth * BackgroundSmooth); diff --git a/PhotoLocator/BitmapOperations/HoleClosingOperation.cs b/PhotoLocator/BitmapOperations/HoleClosingOperation.cs index d62c1ad..cbdacd4 100644 --- a/PhotoLocator/BitmapOperations/HoleClosingOperation.cs +++ b/PhotoLocator/BitmapOperations/HoleClosingOperation.cs @@ -87,10 +87,9 @@ public static void CloseHolesIteratively(FloatBitmap image, FloatBitmap mask, fl } Log.Write($"Detected {remaining * 100 / holes.Length}% holes in mask"); + var replacements = new List<(int X, int Y, float[] Values)>(); while (remaining > 0) { - var replacements = new List<(int X, int Y, float[] Values)>(); - for (var y = 0; y < height; y++) for (var x = 0; x < width; x++) { @@ -134,6 +133,7 @@ public static void CloseHolesIteratively(FloatBitmap image, FloatBitmap mask, fl holes[replacement.Y * width + replacement.X] = false; } remaining -= replacements.Count; + replacements.Clear(); } } }