From fd40f8dece2ae51ed7662b8ce5cf4f3cf1d3885c Mon Sep 17 00:00:00 2001 From: hexawyz <8518235+hexawyz@users.noreply.github.com> Date: Mon, 10 Feb 2025 01:19:32 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Automatic=20rotation=20of=20images?= =?UTF-8?q?=20for=20embedded=20monitors.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes the StreamDeck use case for now, even though setting images through Exo currently serves no useful purpose. --- .../Features/EmbeddedMonitorFeatures.cs | 9 +- src/Exo/Core/Exo.Core/Images/ImageRotation.cs | 9 ++ .../StreamDeckDeviceDriver.cs | 3 +- .../Exo.Devices.Nzxt.Kraken/KrakenDriver.cs | 2 +- .../EmbeddedMonitorService.cs | 14 ++++ .../Exo.Service.Core/ImageStorageService.cs | 84 ++++++++++++------- 6 files changed, 89 insertions(+), 32 deletions(-) create mode 100644 src/Exo/Core/Exo.Core/Images/ImageRotation.cs diff --git a/src/Exo/Core/Exo.Core/Features/EmbeddedMonitorFeatures.cs b/src/Exo/Core/Exo.Core/Features/EmbeddedMonitorFeatures.cs index b7411975..0f14bc15 100644 --- a/src/Exo/Core/Exo.Core/Features/EmbeddedMonitorFeatures.cs +++ b/src/Exo/Core/Exo.Core/Features/EmbeddedMonitorFeatures.cs @@ -117,9 +117,10 @@ public interface IEmbeddedMonitorBuiltInGraphics : IEmbeddedMonitor public readonly record struct EmbeddedMonitorInformation { - public EmbeddedMonitorInformation(MonitorShape shape, Size imageSize, PixelFormat pixelFormat, ImageFormats supportedImageFormats, bool hasAnimationSupport) + public EmbeddedMonitorInformation(MonitorShape shape, ImageRotation defaultRotation, Size imageSize, PixelFormat pixelFormat, ImageFormats supportedImageFormats, bool hasAnimationSupport) { Shape = shape; + DefaultRotation = defaultRotation; ImageSize = imageSize; PixelFormat = pixelFormat; SupportedImageFormats = supportedImageFormats; @@ -132,6 +133,12 @@ public EmbeddedMonitorInformation(MonitorShape shape, Size imageSize, PixelForma /// The shape of the monitor might mainly be used to optimize image compression if the monitor is non-rectangular. /// public MonitorShape Shape { get; } + /// Gets the default rotation of the monitor. + /// + /// Some monitors are by design put in a non-native orientation. As such, the proper display of images on these monitors require preliminary rotation. + /// This is, for example, the case of displays in some StreamDeck devices. + /// + public ImageRotation DefaultRotation { get; } /// Gets the image size of the monitor. public Size ImageSize { get; } /// Gets the effective pixel format of the monitor. diff --git a/src/Exo/Core/Exo.Core/Images/ImageRotation.cs b/src/Exo/Core/Exo.Core/Images/ImageRotation.cs new file mode 100644 index 00000000..2cae0c69 --- /dev/null +++ b/src/Exo/Core/Exo.Core/Images/ImageRotation.cs @@ -0,0 +1,9 @@ +namespace Exo.Images; + +public enum ImageRotation : byte +{ + None, + Rotate90, + Rotate180, + Rotate270, +} diff --git a/src/Exo/Devices/Exo.Devices.Elgato.StreamDeck/StreamDeckDeviceDriver.cs b/src/Exo/Devices/Exo.Devices.Elgato.StreamDeck/StreamDeckDeviceDriver.cs index 652b61a1..d507e24f 100644 --- a/src/Exo/Devices/Exo.Devices.Elgato.StreamDeck/StreamDeckDeviceDriver.cs +++ b/src/Exo/Devices/Exo.Devices.Elgato.StreamDeck/StreamDeckDeviceDriver.cs @@ -268,7 +268,8 @@ public Button(StreamDeckDeviceDriver driver, byte buttonId) Guid IEmbeddedMonitor.MonitorId => _driver._buttonIds[_keyIndex]; // Bitmap seems to not work at all. Until I find a way to understand how colors are mapped, it is better to disable it. (e.g. black would give dark purple, white would give maroon) - EmbeddedMonitorInformation IEmbeddedMonitor.MonitorInformation => new(MonitorShape.Square, _driver.ButtonImageSize, PixelFormat.B8G8R8, /*ImageFormats.Bitmap | */ImageFormats.Jpeg, false); + EmbeddedMonitorInformation IEmbeddedMonitor.MonitorInformation + => new(MonitorShape.Square, ImageRotation.Rotate180, _driver.ButtonImageSize, PixelFormat.B8G8R8, /*ImageFormats.Bitmap | */ImageFormats.Jpeg, false); async ValueTask IEmbeddedMonitor.SetImageAsync(UInt128 imageId, ImageFormat imageFormat, ReadOnlyMemory data, CancellationToken cancellationToken) { diff --git a/src/Exo/Devices/Exo.Devices.Nzxt.Kraken/KrakenDriver.cs b/src/Exo/Devices/Exo.Devices.Nzxt.Kraken/KrakenDriver.cs index 808314f7..b6644337 100644 --- a/src/Exo/Devices/Exo.Devices.Nzxt.Kraken/KrakenDriver.cs +++ b/src/Exo/Devices/Exo.Devices.Nzxt.Kraken/KrakenDriver.cs @@ -284,7 +284,7 @@ static uint GetColor(byte r, byte g, byte b) ImmutableArray ICoolingControllerFeature.Coolers => ImmutableCollectionsMarshal.AsImmutableArray(_coolers); Guid IEmbeddedMonitor.MonitorId => MonitorId; - EmbeddedMonitorInformation IEmbeddedMonitor.MonitorInformation => new(MonitorShape.Circle, new(_imageWidth, _imageHeight), PixelFormat.R8G8B8X8, ImageFormats.Raw | ImageFormats.Gif, true); + EmbeddedMonitorInformation IEmbeddedMonitor.MonitorInformation => new(MonitorShape.Circle, ImageRotation.None, new(_imageWidth, _imageHeight), PixelFormat.R8G8B8X8, ImageFormats.Raw | ImageFormats.Gif, true); IDeviceFeatureSet IDeviceDriver.Features => _genericFeatures; IDeviceFeatureSet IDeviceDriver.Features => _sensorFeatures; diff --git a/src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs b/src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs index dc82ffff..f6d32b81 100644 --- a/src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs +++ b/src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs @@ -87,6 +87,7 @@ private sealed class EmbeddedMonitorState // Metadata private readonly Guid _id; private MonitorShape _shape; + private ImageRotation _defaultRotation; private ushort _width; private ushort _height; private PixelFormat _pixelFormat; @@ -105,6 +106,7 @@ public EmbeddedMonitorState ( Guid id, MonitorShape shape, + ImageRotation defaultRotation, ushort width, ushort height, PixelFormat pixelFormat, @@ -116,6 +118,7 @@ PersistedMonitorConfiguration configuration { _id = id; _shape = shape; + _defaultRotation = defaultRotation; _width = width; _height = height; _pixelFormat = pixelFormat; @@ -185,6 +188,11 @@ public bool SetOnline(IEmbeddedMonitor monitor) _shape = information.Shape; hasChanged = true; } + if (_defaultRotation != information.DefaultRotation) + { + _defaultRotation = information.DefaultRotation; + hasChanged = true; + } if (_width != information.ImageSize.Width) { _width = checked((ushort)information.ImageSize.Width); @@ -292,6 +300,7 @@ private async Task SetImageAsyncCore(ImageStorageService imageStorageService, UI (_capabilities & EmbeddedMonitorCapabilities.AnimatedImages) != 0 ? _imageFormats & ImageFormats.Gif : 0, _pixelFormat, new(_width, _height), + _defaultRotation, _shape == MonitorShape.Circle ); using (imageFile) @@ -329,6 +338,7 @@ public PersistedEmbeddedMonitorInformation CreatePersistedInformation() => new() { Shape = _shape, + DefaultRotation = _defaultRotation, Width = _width, Height = _height, PixelFormat = _pixelFormat, @@ -342,6 +352,7 @@ public EmbeddedMonitorInformation CreateInformation() { MonitorId = _id, Shape = _shape, + DefaultRotation = _defaultRotation, ImageSize = new(_width, _height), PixelFormat = _pixelFormat, SupportedImageFormats = _imageFormats, @@ -377,6 +388,7 @@ public EmbeddedMonitorConfigurationWatchNotification CreateConfigurationNotifica private readonly struct PersistedEmbeddedMonitorInformation { public required MonitorShape Shape { get; init; } + public required ImageRotation DefaultRotation { get; init; } public required ushort Width { get; init; } public required ushort Height { get; init; } public required PixelFormat PixelFormat { get; init; } @@ -444,6 +456,7 @@ CancellationToken cancellationToken ( embeddedMonitorId, info.Shape, + info.DefaultRotation, info.Width, info.Height, info.PixelFormat, @@ -837,6 +850,7 @@ public readonly struct EmbeddedMonitorInformation { public required Guid MonitorId { get; init; } public required MonitorShape Shape { get; init; } + public required ImageRotation DefaultRotation { get; init; } public required Size ImageSize { get; init; } public required PixelFormat PixelFormat { get; init; } public required ImageFormats SupportedImageFormats { get; init; } diff --git a/src/Exo/Service/Exo.Service.Core/ImageStorageService.cs b/src/Exo/Service/Exo.Service.Core/ImageStorageService.cs index dc82cc6a..3734b852 100644 --- a/src/Exo/Service/Exo.Service.Core/ImageStorageService.cs +++ b/src/Exo/Service/Exo.Service.Core/ImageStorageService.cs @@ -333,6 +333,7 @@ public ImageFile GetImageFile(UInt128 imageId) ImageFormats targetAnimatedFormats, PixelFormat targetPixelFormat, Size targetSize, + ImageRotation targetRotation, bool shouldApplyCircularMask ) { @@ -344,12 +345,13 @@ bool shouldApplyCircularMask if (sourceRectangle.Left + sourceRectangle.Width > metadata.Width || sourceRectangle.Top + sourceRectangle.Height > metadata.Height) throw new ArgumentException(nameof(sourceRectangle)); // Determine now if the image already has the correct size. We want to avoid reprocessing an image that may already be correct. - bool isCorrectSize = sourceRectangle.Left == 0 && - sourceRectangle.Top == 0 && - sourceRectangle.Width == metadata.Width && - sourceRectangle.Height == metadata.Height && - targetSize.Width == metadata.Width && - targetSize.Height == metadata.Height; + bool shouldCrop = sourceRectangle.Left != 0 || + sourceRectangle.Top != 0 || + sourceRectangle.Width != metadata.Width || + sourceRectangle.Height != metadata.Height; + bool shouldResize = targetSize.Width == sourceRectangle.Width && + targetSize.Height == sourceRectangle.Height; + bool isCorrectSize = !(shouldCrop || shouldResize); // First and foremost, adjust the animation stripping requirement based on the image and the supported formats of the device. bool shouldStripAnimations = metadata.IsAnimated && targetAnimatedFormats == 0; @@ -400,15 +402,19 @@ bool shouldApplyCircularMask // If we re-processed the image everytime, we would prevent this. if (targetFormat == ImageFormat.Gif && isCorrectSize) shouldApplyCircularMask = false; - Span payload = stackalloc byte[30]; + var operations = ImageOperations.None; + + if (shouldCrop) operations |= ImageOperations.Crop; + if (shouldResize) operations |= ImageOperations.Resize; + if (shouldApplyCircularMask) operations |= ImageOperations.CircularMask; + if (shouldStripAnimations) operations |= ImageOperations.StripAnimation; - payload[0] = (byte) - ( - shouldApplyCircularMask ? - (shouldStripAnimations ? ImageOperationType.CircularTargetAdjustStripAnimation : ImageOperationType.CircularTargetAdjust) : - (shouldStripAnimations ? ImageOperationType.RectangularTargetAdjustStripAnimation : ImageOperationType.RectangularTargetAdjust) - ); + // ⚠️ Just straight output the rotation here. Multiplication should be optimized to a shift by the JIT, but we keep sync with enum values that way. + operations |= (ImageOperations)((uint)targetRotation * (uint)ImageOperations.Rotate90); + Span payload = stackalloc byte[30]; + + payload[0] = (byte)operations; payload[1] = (byte)targetFormat; LittleEndian.Write(ref payload[2], imageId); LittleEndian.Write(ref payload[18], (ushort)sourceRectangle.Left); @@ -435,7 +441,7 @@ bool shouldApplyCircularMask using (var image = GetImageFile(imageId)) using (var stream = new MemoryStream()) { - TransformImage(stream, image, sourceRectangle, targetFormat, targetPixelFormat, targetSize, shouldStripAnimations, shouldApplyCircularMask); + TransformImage(stream, operations, image, sourceRectangle, targetFormat, targetPixelFormat, targetSize); var physicalImageId = XxHash128.HashToUInt128(stream.GetBuffer().AsSpan(0, (int)stream.Length), PhysicalImageIdHashSeed); string fileName = GetFileName(_imageCacheDirectory, physicalImageId); // Assume that if a file exists, it is already correct. We want to avoid wearing the disk if we don't need to. @@ -451,13 +457,12 @@ bool shouldApplyCircularMask private void TransformImage ( Stream stream, + ImageOperations operations, ImageFile originalImage, Rectangle sourceRectangle, ImageFormat targetFormat, PixelFormat targetPixelFormat, - Size targetSize, - bool shouldStripAnimations, - bool applyCircularMask + Size targetSize ) { using (var memoryManager = originalImage.CreateMemoryManager()) @@ -505,7 +510,7 @@ bool applyCircularMask } } - if (shouldStripAnimations && image.Frames.Count > 1) + if ((operations & ImageOperations.StripAnimation) != 0 && image.Frames.Count > 1) { // TODO: Also clear frame metadata related to animation. do @@ -519,18 +524,30 @@ bool applyCircularMask ( ctx => { - ctx.AutoOrient() - .Crop(new(sourceRectangle.Left, sourceRectangle.Top, sourceRectangle.Width, sourceRectangle.Height)) - .Resize + // TODO: Merge this with rotation. + // Basically, it should be possible to avoid explicit rotation if the requested rotation already matches the EXIF rotation. + // Meaning that EXIF rotation should be added to image metadata. + ctx.AutoOrient(); + + if ((operations & ImageOperations.Crop) != 0) ctx.Crop(new(sourceRectangle.Left, sourceRectangle.Top, sourceRectangle.Width, sourceRectangle.Height)); + if ((operations & ImageOperations.Resize) != 0) + { + ctx.Resize ( targetSize.Width, targetSize.Height, resampler, true ); - if (applyCircularMask) + } + var rotation = (ImageRotation)((uint)(operations & ImageOperations.Rotate270) / (uint)ImageOperations.Rotate90); + if (rotation != ImageRotation.None) + { + ctx.Rotate((RotateMode)(90 * (uint)rotation)); + } + if ((operations & ImageOperations.CircularMask) != 0) { - // TODO: For GIF, should find the index of any existing + // TODO: For GIF, selected color could still be improved to pick a pixel already within the circle. ctx.ApplyProcessor(new CircleCroppingProcessor(maskColor, (byte)(targetFormat == ImageFormat.Jpeg ? 3 : 2))); } } @@ -728,11 +745,20 @@ public async ValueTask RemoveImageAsync(string imageName, CancellationToken canc } } -internal enum ImageOperationType : byte +[Flags] +internal enum ImageOperations : byte { - Unknown = 0, - RectangularTargetAdjust = 1, - RectangularTargetAdjustStripAnimation = 2, - CircularTargetAdjust = 3, - CircularTargetAdjustStripAnimation = 4, + None = 0, + + // One bit these individual operations that can be applied to images for device adaptation. + Crop = 1, + Resize = 2, + CircularMask = 4, + StripAnimation = 8, + + // Two bits for rotations + // ⚠️ Be careful that these values must be synchronized with ImageRotation. We must be able to quickly insert and extract those. + Rotate90 = 16, + Rotate180 = 32, + Rotate270 = 48, }