Skip to content

Commit

Permalink
✨ Automatic rotation of images for embedded monitors.
Browse files Browse the repository at this point in the history
This fixes the StreamDeck use case for now, even though setting images through Exo currently serves no useful purpose.
  • Loading branch information
hexawyz committed Feb 10, 2025
1 parent 85a6e53 commit fd40f8d
Show file tree
Hide file tree
Showing 6 changed files with 89 additions and 32 deletions.
9 changes: 8 additions & 1 deletion src/Exo/Core/Exo.Core/Features/EmbeddedMonitorFeatures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
/// </remarks>
public MonitorShape Shape { get; }
/// <summary>Gets the default rotation of the monitor.</summary>
/// <remarks>
/// 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.
/// </remarks>
public ImageRotation DefaultRotation { get; }
/// <summary>Gets the image size of the monitor.</summary>
public Size ImageSize { get; }
/// <summary>Gets the effective pixel format of the monitor.</summary>
Expand Down
9 changes: 9 additions & 0 deletions src/Exo/Core/Exo.Core/Images/ImageRotation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Exo.Images;

public enum ImageRotation : byte
{
None,
Rotate90,
Rotate180,
Rotate270,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte> data, CancellationToken cancellationToken)
{
Expand Down
2 changes: 1 addition & 1 deletion src/Exo/Devices/Exo.Devices.Nzxt.Kraken/KrakenDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ static uint GetColor(byte r, byte g, byte b)
ImmutableArray<ICooler> 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<IGenericDeviceFeature> IDeviceDriver<IGenericDeviceFeature>.Features => _genericFeatures;
IDeviceFeatureSet<ISensorDeviceFeature> IDeviceDriver<ISensorDeviceFeature>.Features => _sensorFeatures;
Expand Down
14 changes: 14 additions & 0 deletions src/Exo/Service/Exo.Service.Core/EmbeddedMonitorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -105,6 +106,7 @@ public EmbeddedMonitorState
(
Guid id,
MonitorShape shape,
ImageRotation defaultRotation,
ushort width,
ushort height,
PixelFormat pixelFormat,
Expand All @@ -116,6 +118,7 @@ PersistedMonitorConfiguration configuration
{
_id = id;
_shape = shape;
_defaultRotation = defaultRotation;
_width = width;
_height = height;
_pixelFormat = pixelFormat;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -329,6 +338,7 @@ public PersistedEmbeddedMonitorInformation CreatePersistedInformation()
=> new()
{
Shape = _shape,
DefaultRotation = _defaultRotation,
Width = _width,
Height = _height,
PixelFormat = _pixelFormat,
Expand All @@ -342,6 +352,7 @@ public EmbeddedMonitorInformation CreateInformation()
{
MonitorId = _id,
Shape = _shape,
DefaultRotation = _defaultRotation,
ImageSize = new(_width, _height),
PixelFormat = _pixelFormat,
SupportedImageFormats = _imageFormats,
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -444,6 +456,7 @@ CancellationToken cancellationToken
(
embeddedMonitorId,
info.Shape,
info.DefaultRotation,
info.Width,
info.Height,
info.PixelFormat,
Expand Down Expand Up @@ -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; }
Expand Down
84 changes: 55 additions & 29 deletions src/Exo/Service/Exo.Service.Core/ImageStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ public ImageFile GetImageFile(UInt128 imageId)
ImageFormats targetAnimatedFormats,
PixelFormat targetPixelFormat,
Size targetSize,
ImageRotation targetRotation,
bool shouldApplyCircularMask
)
{
Expand All @@ -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;
Expand Down Expand Up @@ -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<byte> 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<byte> 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);
Expand All @@ -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.
Expand All @@ -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())
Expand Down Expand Up @@ -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
Expand All @@ -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)));
}
}
Expand Down Expand Up @@ -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,
}

0 comments on commit fd40f8d

Please sign in to comment.