Skip to content

Commit 54fdddf

Browse files
committed
Implement MediaPicker Image Rotate
1 parent 1e3d94b commit 54fdddf

17 files changed

+866
-20
lines changed

src/Essentials/samples/Samples/View/MediaPickerPage.xaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@
8989
HorizontalOptions="Center"
9090
FontAttributes="Bold" />
9191
</Grid>
92+
93+
<!-- Auto-rotate Image -->
94+
<Grid ColumnDefinitions="2*,Auto">
95+
<Label Grid.Column="0"
96+
Text="Auto-rotate from EXIF"
97+
VerticalOptions="Center"
98+
TextColor="{AppThemeBinding Light=#6C757D, Dark=#CED4DA}" />
99+
<Switch Grid.Column="1"
100+
IsToggled="{Binding PickerRotateImage, Mode=TwoWay}"
101+
OnColor="{AppThemeBinding Light=#007BFF, Dark=#0D6EFD}" />
102+
</Grid>
92103
</VerticalStackLayout>
93104
</Border>
94105

src/Essentials/samples/Samples/ViewModel/MediaPickerViewModel.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public class MediaPickerViewModel : BaseViewModel
2828
int pickerCompressionQuality = 100;
2929
int pickerMaximumWidth = 0;
3030
int pickerMaximumHeight = 0;
31+
bool pickerRotateImage = false;
3132
long imageByteLength = 0;
3233
string imageDimensions = "";
3334
private ObservableCollection<PhotoInfo> photoList = [];
@@ -74,6 +75,12 @@ public int PickerMaximumHeight
7475
set => SetProperty(ref pickerMaximumHeight, value);
7576
}
7677

78+
public bool PickerRotateImage
79+
{
80+
get => pickerRotateImage;
81+
set => SetProperty(ref pickerRotateImage, value);
82+
}
83+
7784
public long ImageByteLength
7885
{
7986
get => imageByteLength;
@@ -121,6 +128,7 @@ async void DoPickPhoto()
121128
CompressionQuality = PickerCompressionQuality,
122129
MaximumWidth = PickerMaximumWidth > 0 ? PickerMaximumWidth : null,
123130
MaximumHeight = PickerMaximumHeight > 0 ? PickerMaximumHeight : null,
131+
RotateImage = PickerRotateImage
124132
});
125133

126134
await LoadPhotoAsync(photo);
@@ -143,6 +151,7 @@ async void DoCapturePhoto()
143151
CompressionQuality = PickerCompressionQuality,
144152
MaximumWidth = PickerMaximumWidth > 0 ? PickerMaximumWidth : null,
145153
MaximumHeight = PickerMaximumHeight > 0 ? PickerMaximumHeight : null,
154+
RotateImage = PickerRotateImage
146155
});
147156

148157
await LoadPhotoAsync(photo);
@@ -163,6 +172,7 @@ async void DoPickVideo()
163172
{
164173
Title = "Pick a video",
165174
SelectionLimit = PickerSelectionLimit,
175+
RotateImage = PickerRotateImage
166176
});
167177

168178
ShowPhoto = false;
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#nullable enable
2+
using System;
3+
using System.IO;
4+
using System.Threading.Tasks;
5+
using Android.Graphics;
6+
using Android.Media;
7+
using Microsoft.Maui.ApplicationModel;
8+
using Path = System.IO.Path;
9+
using Stream = System.IO.Stream;
10+
11+
namespace Microsoft.Maui.Media
12+
{
13+
internal static partial class ExifImageRotator
14+
{
15+
public static partial async Task<Stream> RotateImageAsync(Stream inputStream, string originalFileName)
16+
{
17+
if (inputStream == null)
18+
return new MemoryStream();
19+
20+
// Reset stream position
21+
if (inputStream.CanSeek)
22+
inputStream.Position = 0;
23+
24+
// Read the input stream into a byte array
25+
byte[] bytes;
26+
using (var memoryStream = new MemoryStream())
27+
{
28+
await inputStream.CopyToAsync(memoryStream);
29+
bytes = memoryStream.ToArray();
30+
}
31+
32+
try
33+
{
34+
// Load the bitmap from bytes
35+
var originalBitmap = await Task.Run(() => BitmapFactory.DecodeByteArray(bytes, 0, bytes.Length));
36+
if (originalBitmap == null)
37+
return new MemoryStream(bytes);
38+
39+
// Get EXIF orientation
40+
int orientation = GetExifOrientation(bytes);
41+
42+
// If orientation is normal, return original
43+
if (orientation == 1)
44+
{
45+
return new MemoryStream(bytes);
46+
}
47+
48+
// Apply EXIF orientation correction using SetRotate(0) to preserve original EXIF behavior
49+
Bitmap? rotatedBitmap = ApplyExifOrientation(originalBitmap);
50+
if (rotatedBitmap == null)
51+
{
52+
return new MemoryStream(bytes);
53+
}
54+
55+
// Clean up the original bitmap if we created a new one
56+
if (rotatedBitmap != originalBitmap)
57+
originalBitmap.Recycle();
58+
59+
// Convert the rotated bitmap back to a stream
60+
var resultStream = new MemoryStream();
61+
bool usePng = !string.IsNullOrEmpty(originalFileName) &&
62+
Path.GetExtension(originalFileName).ToLowerInvariant() == ".png";
63+
64+
var compressResult = await Task.Run(() =>
65+
{
66+
try
67+
{
68+
if (usePng)
69+
{
70+
return rotatedBitmap.Compress(Bitmap.CompressFormat.Png!, 100, resultStream);
71+
}
72+
else
73+
{
74+
return rotatedBitmap.Compress(Bitmap.CompressFormat.Jpeg!, 100, resultStream);
75+
}
76+
}
77+
catch (Exception ex)
78+
{
79+
System.Console.WriteLine($"Compression error: {ex}");
80+
return false;
81+
}
82+
finally
83+
{
84+
rotatedBitmap?.Recycle();
85+
}
86+
});
87+
88+
if (!compressResult)
89+
return new MemoryStream(bytes);
90+
91+
resultStream.Position = 0;
92+
return resultStream;
93+
}
94+
catch (Exception ex)
95+
{
96+
System.Console.WriteLine($"Exception in RotateImageAsync: {ex}");
97+
return new MemoryStream(bytes);
98+
}
99+
}
100+
101+
/// <summary>
102+
/// Extract EXIF orientation from image bytes
103+
/// </summary>
104+
private static int GetExifOrientation(byte[] imageBytes)
105+
{
106+
try
107+
{
108+
// Create a temporary file to read EXIF data
109+
var tempFileName = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.jpg");
110+
using (var fileStream = System.IO.File.Create(tempFileName))
111+
{
112+
fileStream.Write(imageBytes, 0, imageBytes.Length);
113+
}
114+
115+
var exif = new ExifInterface(tempFileName);
116+
int orientation = exif.GetAttributeInt(ExifInterface.TagOrientation, 1);
117+
118+
// Clean up temp file
119+
try
120+
{
121+
System.IO.File.Delete(tempFileName);
122+
}
123+
catch
124+
{
125+
// Ignore cleanup failures
126+
}
127+
128+
return orientation;
129+
}
130+
catch
131+
{
132+
return 1; // Default to normal orientation
133+
}
134+
}
135+
136+
/// <summary>
137+
/// Apply EXIF orientation correction by preserving original EXIF behavior
138+
/// </summary>
139+
private static Bitmap? ApplyExifOrientation(Bitmap bitmap)
140+
{
141+
try
142+
{
143+
// Use SetRotate(0) to preserve original EXIF orientation behavior
144+
var matrix = new Matrix();
145+
matrix.SetRotate(0);
146+
return Bitmap.CreateBitmap(bitmap, 0, 0, bitmap.Width, bitmap.Height, matrix, true);
147+
}
148+
catch (Exception ex)
149+
{
150+
System.Console.WriteLine($"Error applying EXIF orientation: {ex}");
151+
return bitmap;
152+
}
153+
}
154+
}
155+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#nullable enable
2+
using System;
3+
using System.IO;
4+
using System.Threading.Tasks;
5+
using CoreGraphics;
6+
using Foundation;
7+
using UIKit;
8+
9+
namespace Microsoft.Maui.Media
10+
{
11+
internal static partial class ExifImageRotator
12+
{
13+
/// <summary>
14+
/// Gets the correct size for the destination image based on the orientation
15+
/// </summary>
16+
private static CGSize GetSizeForOrientation(UIImage image)
17+
{
18+
// For orientations that rotate 90 or 270 degrees, we need to swap width and height
19+
switch (image.Orientation)
20+
{
21+
case UIImageOrientation.Left:
22+
case UIImageOrientation.LeftMirrored:
23+
case UIImageOrientation.Right:
24+
case UIImageOrientation.RightMirrored:
25+
return new CGSize(image.Size.Height, image.Size.Width);
26+
default:
27+
return image.Size;
28+
}
29+
}
30+
31+
/// <summary>
32+
/// Applies the appropriate affine transformation to the graphics context
33+
/// based on the orientation of the image
34+
/// </summary>
35+
private static void ApplyOrientationTransformation(CGContext context, UIImage image, CGSize destSize)
36+
{
37+
var width = image.Size.Width;
38+
var height = image.Size.Height;
39+
40+
// First translate the context to the right position
41+
switch (image.Orientation)
42+
{
43+
case UIImageOrientation.Down: // 180° rotation
44+
context.TranslateCTM(width, height);
45+
context.RotateCTM((nfloat)Math.PI);
46+
break;
47+
48+
case UIImageOrientation.Left: // 90° CCW - In iOS, Left means "the left side becomes the top"
49+
context.TranslateCTM(0, width);
50+
context.RotateCTM((nfloat)(-Math.PI / 2.0));
51+
break;
52+
53+
case UIImageOrientation.Right: // 90° CW - In iOS, Right means "the right side becomes the top"
54+
context.TranslateCTM(height, 0);
55+
context.RotateCTM((nfloat)(Math.PI / 2.0));
56+
break;
57+
58+
case UIImageOrientation.UpMirrored: // Horizontal flip
59+
context.TranslateCTM(width, 0);
60+
context.ScaleCTM(-1, 1);
61+
break;
62+
63+
case UIImageOrientation.DownMirrored: // 180° rotation + horizontal flip
64+
context.TranslateCTM(width, height);
65+
context.RotateCTM((nfloat)Math.PI);
66+
context.TranslateCTM(width, 0);
67+
context.ScaleCTM(-1, 1);
68+
break;
69+
70+
case UIImageOrientation.LeftMirrored: // 90° CCW + horizontal flip
71+
context.TranslateCTM(0, width);
72+
context.RotateCTM((nfloat)(-Math.PI / 2.0));
73+
context.TranslateCTM(height, 0);
74+
context.ScaleCTM(-1, 1);
75+
break;
76+
77+
case UIImageOrientation.RightMirrored: // 90° CW + horizontal flip
78+
context.TranslateCTM(height, 0);
79+
context.RotateCTM((nfloat)(Math.PI / 2.0));
80+
context.TranslateCTM(width, 0);
81+
context.ScaleCTM(-1, 1);
82+
break;
83+
84+
default: // No transformation needed for Up orientation
85+
break;
86+
}
87+
}
88+
89+
public static partial async Task<Stream> RotateImageAsync(Stream inputStream, string originalFileName)
90+
{
91+
if (inputStream == null)
92+
return new MemoryStream();
93+
94+
// Reset stream position
95+
if (inputStream.CanSeek)
96+
inputStream.Position = 0;
97+
98+
// Read the input stream into NSData
99+
NSData? imageData;
100+
using (var memoryStream = new MemoryStream())
101+
{
102+
await inputStream.CopyToAsync(memoryStream);
103+
imageData = NSData.FromArray(memoryStream.ToArray());
104+
}
105+
106+
if (imageData == null)
107+
return new MemoryStream();
108+
109+
try
110+
{
111+
// Load UIImage from NSData
112+
UIImage? image = UIImage.LoadFromData(imageData);
113+
if (image == null)
114+
return new MemoryStream();
115+
116+
// Log the orientation for debugging
117+
Console.WriteLine($"EXIF Orientation: {image.Orientation}");
118+
119+
// Check if the image already has the correct orientation
120+
if (image.Orientation == UIImageOrientation.Up)
121+
{
122+
// No rotation needed
123+
return new MemoryStream(imageData.ToArray());
124+
}
125+
126+
// Create a corrected image with proper orientation
127+
// The key fix: create a new image with the Up orientation
128+
// This automatically applies the correct rotation based on the EXIF data
129+
UIImage correctedImage;
130+
131+
if (image.CGImage != null)
132+
{
133+
correctedImage = UIImage.FromImage(
134+
image.CGImage,
135+
image.CurrentScale,
136+
UIImageOrientation.Up);
137+
}
138+
else
139+
{
140+
// Fallback if we couldn't get the CGImage
141+
correctedImage = image;
142+
}
143+
144+
// Convert back to NSData with appropriate compression
145+
NSData? resultData;
146+
var isPngFormat = !string.IsNullOrEmpty(originalFileName) &&
147+
originalFileName.EndsWith(".png", StringComparison.OrdinalIgnoreCase);
148+
149+
if (isPngFormat)
150+
{
151+
resultData = correctedImage.AsPNG();
152+
}
153+
else
154+
{
155+
// Use JPEG with high quality
156+
resultData = correctedImage.AsJPEG(1.0f);
157+
}
158+
159+
if (resultData == null)
160+
return new MemoryStream();
161+
162+
// Return the corrected image data as a stream
163+
return new MemoryStream(resultData.ToArray());
164+
}
165+
catch (Exception ex)
166+
{
167+
Console.WriteLine($"Exception in RotateImageAsync: {ex}");
168+
// If anything went wrong, return a new empty stream
169+
return new MemoryStream(imageData.ToArray());
170+
}
171+
}
172+
}
173+
}

0 commit comments

Comments
 (0)