diff --git a/Tests/HdrTests.cs b/Tests/HdrTests.cs new file mode 100644 index 00000000..7d1ca06a --- /dev/null +++ b/Tests/HdrTests.cs @@ -0,0 +1,160 @@ +using System.Drawing; +using System.Drawing.Imaging; +using Text_Grab.Utilities; +using Xunit; + +namespace Tests; + +public class HdrTests +{ + [Fact] + public void ConvertHdrToSdr_WithNullBitmap_ReturnsNull() + { + // Arrange + Bitmap? nullBitmap = null; + + // Act + Bitmap? result = HdrUtilities.ConvertHdrToSdr(nullBitmap); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ConvertHdrToSdr_WithValidBitmap_ReturnsNewBitmap() + { + // Arrange + Bitmap testBitmap = new(100, 100, PixelFormat.Format32bppArgb); + using Graphics g = Graphics.FromImage(testBitmap); + // Fill with white color (simulating HDR bright pixels) + g.Clear(Color.White); + + // Act + Bitmap result = HdrUtilities.ConvertHdrToSdr(testBitmap); + + // Assert + Assert.NotNull(result); + Assert.NotSame(testBitmap, result); + Assert.Equal(testBitmap.Width, result.Width); + Assert.Equal(testBitmap.Height, result.Height); + + // Cleanup + testBitmap.Dispose(); + result.Dispose(); + } + + [Fact] + public void ConvertHdrToSdr_WithBrightPixels_ReducesBrightness() + { + // Arrange + Bitmap testBitmap = new(10, 10, PixelFormat.Format32bppArgb); + // Fill with very bright color + using (Graphics g = Graphics.FromImage(testBitmap)) + { + g.Clear(Color.FromArgb(255, 255, 255, 255)); + } + + // Act + Bitmap result = HdrUtilities.ConvertHdrToSdr(testBitmap); + + // Assert + // The conversion should tone map bright values + // In this case, pure white (255,255,255) should remain relatively close + // but with tone mapping applied + Color centerPixel = result.GetPixel(5, 5); + + // After tone mapping, pixels should still be bright but potentially adjusted + Assert.True(centerPixel.R >= 200, "Red channel should remain bright"); + Assert.True(centerPixel.G >= 200, "Green channel should remain bright"); + Assert.True(centerPixel.B >= 200, "Blue channel should remain bright"); + + // Cleanup + testBitmap.Dispose(); + result.Dispose(); + } + + [Fact] + public void ConvertHdrToSdr_WithMixedPixels_ProcessesCorrectly() + { + // Arrange + Bitmap testBitmap = new(10, 10, PixelFormat.Format32bppArgb); + using (Graphics g = Graphics.FromImage(testBitmap)) + { + // Fill with different colors to test tone mapping + using Brush darkBrush = new SolidBrush(Color.FromArgb(255, 50, 50, 50)); + using Brush brightBrush = new SolidBrush(Color.FromArgb(255, 250, 250, 250)); + + g.FillRectangle(darkBrush, 0, 0, 5, 10); + g.FillRectangle(brightBrush, 5, 0, 5, 10); + } + + // Act + Bitmap result = HdrUtilities.ConvertHdrToSdr(testBitmap); + + // Assert + Assert.NotNull(result); + Color darkPixel = result.GetPixel(2, 5); + Color brightPixel = result.GetPixel(7, 5); + + // Dark pixels should remain relatively dark + Assert.True(darkPixel.R < 100, "Dark pixel should remain dark"); + + // Bright pixels should be tone mapped + Assert.True(brightPixel.R > darkPixel.R, "Bright pixel should be brighter than dark pixel"); + + // Cleanup + testBitmap.Dispose(); + result.Dispose(); + } + + [Fact] + public void ConvertHdrToSdr_PreservesAlphaChannel() + { + // Arrange + Bitmap testBitmap = new(10, 10, PixelFormat.Format32bppArgb); + using (Graphics g = Graphics.FromImage(testBitmap)) + { + using Brush semiTransparentBrush = new SolidBrush(Color.FromArgb(128, 255, 255, 255)); + g.FillRectangle(semiTransparentBrush, 0, 0, 10, 10); + } + + // Act + Bitmap result = HdrUtilities.ConvertHdrToSdr(testBitmap); + + // Assert + Color pixel = result.GetPixel(5, 5); + Assert.Equal(128, pixel.A); + + // Cleanup + testBitmap.Dispose(); + result.Dispose(); + } + + [Fact] + public void IsHdrEnabledAtPoint_WithValidCoordinates_DoesNotThrow() + { + // Arrange + int x = 100; + int y = 100; + + // Act & Assert + // Should not throw exception even if HDR is not available + var exception = Record.Exception(() => HdrUtilities.IsHdrEnabledAtPoint(x, y)); + Assert.Null(exception); + } + + [Fact] + public void IsHdrEnabledAtPoint_WithNegativeCoordinates_ReturnsFalse() + { + // Arrange + int x = -1; + int y = -1; + + // Act + bool result = HdrUtilities.IsHdrEnabledAtPoint(x, y); + + // Assert + // Should return false for invalid coordinates + Assert.False(result); + } +} diff --git a/Text-Grab/NativeMethods.cs b/Text-Grab/NativeMethods.cs index cb15c71c..018ef01a 100644 --- a/Text-Grab/NativeMethods.cs +++ b/Text-Grab/NativeMethods.cs @@ -22,4 +22,59 @@ internal static partial class NativeMethods [LibraryImport("shcore.dll")] public static partial void GetScaleFactorForMonitor(IntPtr hMon, out uint pScale); + + // HDR detection APIs + [DllImport("user32.dll")] + internal static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi); + + [DllImport("gdi32.dll", CharSet = CharSet.Unicode)] + internal static extern IntPtr CreateDC(string? lpszDriver, string lpszDevice, string? lpszOutput, IntPtr lpInitData); + + [DllImport("gdi32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool DeleteDC(IntPtr hdc); + + [DllImport("gdi32.dll")] + internal static extern int GetDeviceCaps(IntPtr hdc, int nIndex); + + public const uint MONITOR_DEFAULTTONEAREST = 0x00000002; + /// + /// Device capability index for GetDeviceCaps to query color management capabilities. + /// + public const int COLORMGMTCAPS = 121; + /// + /// Flag indicating that the device supports HDR (High Dynamic Range). + /// + public const int CM_HDR_SUPPORT = 0x00000001; + + [StructLayout(LayoutKind.Sequential)] + internal struct POINT + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct MONITORINFOEX + { + public uint cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string szDevice; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } } \ No newline at end of file diff --git a/Text-Grab/Utilities/HdrUtilities.cs b/Text-Grab/Utilities/HdrUtilities.cs new file mode 100644 index 00000000..41befe5c --- /dev/null +++ b/Text-Grab/Utilities/HdrUtilities.cs @@ -0,0 +1,173 @@ +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; + +namespace Text_Grab.Utilities; + +public static class HdrUtilities +{ + /// + /// Checks if HDR is enabled on the monitor at the specified screen coordinates. + /// + /// X coordinate on screen + /// Y coordinate on screen + /// True if HDR is enabled on the monitor, false otherwise + public static bool IsHdrEnabledAtPoint(int x, int y) + { + try + { + NativeMethods.POINT pt = new() { X = x, Y = y }; + IntPtr hMonitor = NativeMethods.MonitorFromPoint(pt, NativeMethods.MONITOR_DEFAULTTONEAREST); + + if (hMonitor == IntPtr.Zero) + return false; + + NativeMethods.MONITORINFOEX monitorInfo = new() + { + cbSize = (uint)Marshal.SizeOf() + }; + + if (!NativeMethods.GetMonitorInfo(hMonitor, ref monitorInfo)) + return false; + + IntPtr hdc = NativeMethods.CreateDC(null, monitorInfo.szDevice, null, IntPtr.Zero); + if (hdc == IntPtr.Zero) + return false; + + try + { + int colorCaps = NativeMethods.GetDeviceCaps(hdc, NativeMethods.COLORMGMTCAPS); + return (colorCaps & NativeMethods.CM_HDR_SUPPORT) != 0; + } + finally + { + NativeMethods.DeleteDC(hdc); + } + } + catch + { + return false; + } + } + + /// + /// Converts an HDR bitmap to SDR (Standard Dynamic Range) by applying tone mapping. + /// This fixes the overly bright appearance of screenshots taken on HDR displays. + /// + /// The bitmap to convert + /// A new bitmap with SDR color values, or null if the input is null + public static Bitmap? ConvertHdrToSdr(Bitmap? bitmap) + { + if (bitmap == null) + return null; + + try + { + // Create a new bitmap with the same dimensions + Bitmap result = new(bitmap.Width, bitmap.Height, PixelFormat.Format32bppArgb); + + // Lock both bitmaps for fast pixel access + BitmapData sourceData = bitmap.LockBits( + new Rectangle(0, 0, bitmap.Width, bitmap.Height), + ImageLockMode.ReadOnly, + PixelFormat.Format32bppArgb); + + BitmapData resultData = result.LockBits( + new Rectangle(0, 0, result.Width, result.Height), + ImageLockMode.WriteOnly, + PixelFormat.Format32bppArgb); + + try + { + unsafe + { + byte* sourcePtr = (byte*)sourceData.Scan0; + byte* resultPtr = (byte*)resultData.Scan0; + + int bytes = Math.Abs(sourceData.Stride) * sourceData.Height; + + // Process each pixel + for (int i = 0; i < bytes; i += 4) + { + // Read BGRA values + byte b = sourcePtr[i]; + byte g = sourcePtr[i + 1]; + byte r = sourcePtr[i + 2]; + byte a = sourcePtr[i + 3]; + + // Convert to linear RGB space (0.0 to 1.0) + double rLinear = SrgbToLinear(r / 255.0); + double gLinear = SrgbToLinear(g / 255.0); + double bLinear = SrgbToLinear(b / 255.0); + + // Apply simple tone mapping (Reinhard operator) + // This compresses the HDR range to SDR range + rLinear = ToneMap(rLinear); + gLinear = ToneMap(gLinear); + bLinear = ToneMap(bLinear); + + // Convert back to sRGB space + r = (byte)Math.Clamp((int)(LinearToSrgb(rLinear) * 255.0 + 0.5), 0, 255); + g = (byte)Math.Clamp((int)(LinearToSrgb(gLinear) * 255.0 + 0.5), 0, 255); + b = (byte)Math.Clamp((int)(LinearToSrgb(bLinear) * 255.0 + 0.5), 0, 255); + + // Write BGRA values + resultPtr[i] = b; + resultPtr[i + 1] = g; + resultPtr[i + 2] = r; + resultPtr[i + 3] = a; + } + } + } + finally + { + bitmap.UnlockBits(sourceData); + result.UnlockBits(resultData); + } + + return result; + } + catch + { + // If conversion fails, return original bitmap + return bitmap; + } + } + + /// + /// Converts sRGB color value to linear RGB. + /// + private static double SrgbToLinear(double value) + { + if (value <= 0.04045) + return value / 12.92; + else + return Math.Pow((value + 0.055) / 1.055, 2.4); + } + + /// + /// Converts linear RGB value to sRGB. + /// + private static double LinearToSrgb(double value) + { + if (value <= 0.0031308) + return 12.92 * value; + else + return 1.055 * Math.Pow(value, 1.0 / 2.4) - 0.055; + } + + /// + /// Applies tone mapping to compress HDR values to SDR range. + /// Uses a modified Reinhard operator with exposure adjustment to preserve mid-tones while compressing highlights. + /// Formula: L_out = (L_in * exposure) / (1 + L_in * exposure) + /// + private static double ToneMap(double value) + { + const double exposure = 0.8; // Adjust exposure to darken the image slightly + value *= exposure; + + // Apply tone mapping + return value / (1.0 + value); + } +} diff --git a/Text-Grab/Utilities/ImageMethods.cs b/Text-Grab/Utilities/ImageMethods.cs index ae43afd5..c61a34c6 100644 --- a/Text-Grab/Utilities/ImageMethods.cs +++ b/Text-Grab/Utilities/ImageMethods.cs @@ -18,6 +18,27 @@ namespace Text_Grab; public static class ImageMethods { + /// + /// Converts a bitmap from HDR to SDR if HDR is detected at the specified location. + /// + /// The bitmap to potentially convert + /// X coordinate of the center of the captured region + /// Y coordinate of the center of the captured region + /// The SDR-converted bitmap if HDR was detected, otherwise the original bitmap + private static Bitmap ConvertHdrToSdrIfNeeded(Bitmap bitmap, int centerX, int centerY) + { + if (HdrUtilities.IsHdrEnabledAtPoint(centerX, centerY)) + { + Bitmap? sdrBitmap = HdrUtilities.ConvertHdrToSdr(bitmap); + if (sdrBitmap != null && sdrBitmap != bitmap) + { + bitmap.Dispose(); + return sdrBitmap; + } + } + return bitmap; + } + public static Bitmap PadImage(Bitmap image, int minW = 64, int minH = 64) { if (image.Height >= minH && image.Width >= minW) @@ -96,6 +117,10 @@ public static Bitmap GetRegionOfScreenAsBitmap(Rectangle region) using Graphics g = Graphics.FromImage(bmp); g.CopyFromScreen(region.Left, region.Top, 0, 0, bmp.Size, CopyPixelOperation.SourceCopy); + + // Convert HDR to SDR if needed + bmp = ConvertHdrToSdrIfNeeded(bmp, region.Left + region.Width / 2, region.Top + region.Height / 2); + bmp = PadImage(bmp); Singleton.Instance.CacheLastBitmap(bmp); @@ -141,6 +166,10 @@ public static Bitmap GetWindowsBoundsBitmap(Window passedWindow) using Graphics g = Graphics.FromImage(bmp); g.CopyFromScreen(thisCorrectedLeft, thisCorrectedTop, 0, 0, bmp.Size, CopyPixelOperation.SourceCopy); + + // Convert HDR to SDR if needed + bmp = ConvertHdrToSdrIfNeeded(bmp, thisCorrectedLeft + windowWidth / 2, thisCorrectedTop + windowHeight / 2); + return bmp; }