Skip to content

fix(ios): normalize image orientation before crop#117

Open
rayabelcode wants to merge 2 commits into
mrousavy:mainfrom
rayabelcode:fix/ios-crop-resize-orientation
Open

fix(ios): normalize image orientation before crop#117
rayabelcode wants to merge 2 commits into
mrousavy:mainfrom
rayabelcode:fix/ios-crop-resize-orientation

Conversation

@rayabelcode

@rayabelcode rayabelcode commented Apr 17, 2026

Copy link
Copy Markdown

Rescoped + rebased on latest main to clear the merge conflicts.

0.15.1 already shipped fixes for four of the five bugs from the original PR (crop rect size, renderer scale in resize / rotate slow path / createBlankImage, and the rotate slow-path bounding box on iOS and Android). This branch is now reduced to the one bug that is still present in 0.15.1.

The remaining bug: crop drops orientation (iOS)

Image.crop() does not normalize non-.up input images before cgImage.cropping(to:). vision-camera v5's Photo.toImageAsync() constructs UIImage(cgImage:, scale: 1, orientation: sensorOrientation), so uiImage.size is in display space while cgImage is in sensor space. The caller's (startX, startY, endX, endY) rect then lands in the wrong region of cgImage, and UIImage(cgImage:) drops the source orientation in the output.

Fix

When imageOrientation != .up or scale != 1, draw the image through a scale = 1 UIGraphicsImageRenderer to bake orientation into pixels before cropping, then wrap the cropped CGImage with explicit scale: 1, orientation: .up so downstream consumers see consistent state. When the image is already .up at scale == 1, it takes the original no-copy path.

Testing

Verified on iPhone 15 Pro (3x) through a vision-camera v5 card-scanning pipeline (Photo.toImageAsync() -> crop -> resize -> saveToTemporaryFileAsync). Before: the cropped region was wrong and the output was sensor-oriented. After: the crop produces the requested region in the correct orientation.

The branch is one commit ahead of main with no other changes, so it merges cleanly.

@rayabelcode rayabelcode force-pushed the fix/ios-crop-resize-orientation branch from 64b9896 to 43a7280 Compare April 17, 2026 15:06
@rayabelcode rayabelcode changed the title fix(ios): correct crop rect size, orientation, and renderer scale fix(ios+android): crop/resize/rotate correctness (5 bugs) Apr 17, 2026
@mrousavy

Copy link
Copy Markdown
Owner

Thanks for the PR this is great stuff!!

I think we'll be adding some Harness tests for the CI to ensure that we test bugs like these and catch them in CI. I expect the Harness tests CI to go red on current main and then green with your PR - @riteshshukla04 will post an update here soon :)

@riteshshukla04

Copy link
Copy Markdown
Contributor

I added harness on main . So please sync your branch with main . That way the tests can run and we can see the fixes

@rayabelcode rayabelcode force-pushed the fix/ios-crop-resize-orientation branch from c4c1cf5 to 3282c2d Compare May 2, 2026 12:26
@rayabelcode

Copy link
Copy Markdown
Author

Hi @riteshshukla04 @mrousavy - rebased onto current main, so this branch now includes the harness tests from #121. CI should now run against these fixes. Let me know if you'd like any other changes - thanks!

@davdevarm

Copy link
Copy Markdown

Thanks for the great library! I can confirm that cropping does not work correctly on iOS. Thanks @rayabelcode for the fix! Waiting for the fix to be merged.

@davdevarm

Copy link
Copy Markdown

@mrousavy thanks for the great library! Is there any chance the crop and resize fixes will be merged soon? They don't work correctly on iOS.

@mrousavy

mrousavy commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Hey - there are merge conflicts in this PR, I cannot merge anything :)

Image.crop() on iOS did not normalize non-.up input images before
calling cgImage.cropping(to:). vision-camera v5's Photo.toImageAsync()
constructs UIImage(cgImage:, scale: 1, orientation: sensorOrientation),
so uiImage.size is in display space while cgImage is in sensor space.
The caller's (startX, startY, endX, endY) rect then lands in the wrong
region of cgImage, and UIImage(cgImage:) dropped the source orientation
in the output.

Fix: when imageOrientation != .up or scale != 1, draw the image through
a scale=1 UIGraphicsImageRenderer to bake orientation into pixels before
cropping, then wrap the cropped CGImage with explicit scale=1 and .up so
downstream consumers see consistent state.

The other four bugs from the original PR (crop rect size, renderer scale
in resize/rotate/createBlankImage, and the rotate slow-path bounding
box) are already fixed in 0.15.1, so this branch is rescoped to just the
remaining orientation bug, which also resolves the merge conflicts.
@rayabelcode rayabelcode force-pushed the fix/ios-crop-resize-orientation branch from 3282c2d to f6a87f0 Compare June 4, 2026 10:48
@rayabelcode rayabelcode changed the title fix(ios+android): crop/resize/rotate correctness (5 bugs) fix(ios): normalize image orientation before crop Jun 4, 2026
@rayabelcode

Copy link
Copy Markdown
Author

Fixed - rebased on latest main and rescoped. I think it's ready to merge @mrousavy.

@mrousavy

mrousavy commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Wait but didn't my latest commits on main now fix this bug? CI is now green...

If there is still a bug on main I think we should first make sure to reproduce that bug via a Harness test that shows that the CI is actually red, then your PR can make it go green. Without tests I cant really blindly merge that

Builds a non-.up image via a fast-flag rotate (cgImage untouched,
orientation flipped) and crops a display-space rect. Without orientation
normalization iOS clamps the rect to the sensor buffer and returns the
wrong size, so this fails on main and passes with the crop fix. Android
is unaffected and passes on both.
@rayabelcode

Copy link
Copy Markdown
Author

Thanks for looking. I checked current main (HEAD 006c5b9, the 0.15.1 release) and this one isn't fixed yet - #147/#150/#151/#152 were bundle / React-runtime / harness-stability changes, and crop() itself is untouched:

guard let cgImage = uiImage.cgImage else { ... }      // raw, sensor-space cgImage
...
let croppedUiImage = UIImage(cgImage: croppedCgImage) // defaults to .up, drops orientation

There's no imageOrientation normalization before cgImage.cropping(to:), so for any non-.up image (every Photo.toImageAsync() capture, and any EXIF-tagged JPEG) the display-space crop rect gets applied to the sensor-space cgImage - wrong region cropped, orientation dropped.

You're right that this needs a failing test first, and I think CI stays green because the harness builds every image with createBlankImage, which is always .up at scale 1, so the orientation path is never exercised.

I've pushed a repro to the PR (example/__tests__/image-transforms.harness.ts) - no fixture needed. A fast-flag rotate gives a non-.up image (cgImage untouched, orientation flipped, same shape as a camera frame):

it('crops in display space when the image has a non-up orientation', () => {
	const base = makeImage(40, 20);
	const oriented = base.rotate(90, true); // cgImage stays 40x20, displays as 20x40
	expect(oriented.width).toBe(20);
	expect(oriented.height).toBe(40);

	const cropped = oriented.crop(0, 0, 20, 40);
	expect(cropped.width).toBe(20);
	expect(cropped.height).toBe(40);
});

On main (iOS) the last assertion fails: cgImage.cropping(to:) clamps the (0,0,20,40) display rect to the 40x20 sensor buffer and returns 20x20. With the fix it normalizes to a 20x40 .up image first and returns 20x40. Android is unaffected, so it goes red purely from the iOS path - the gate you wanted. The branch is rebased on latest main and merges cleanly now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants