Test iOS image EXIF orientation and pixel normalization

Test iOS image EXIF orientation and pixel normalization

The photo picker shows a portrait image, but the uploaded thumbnail turns sideways. Before changing the crop ratio, check the image’s orientation: a JPEG can store pixels in a landscape layout and use an EXIF orientation tag to tell viewers how to rotate it. iOS may display it correctly through UIImage, while export code or the receiving system handles it differently. We’ll generate a fixed fixture in an iOS test target on a cloud Mac, then verify “displays correctly” and “the file is correct” separately.

Separate orientation metadata from pixels

Record three values: the file’s EXIF orientation, UIImage.imageOrientation after decoding, and the underlying CGImage pixel width and height. An EXIF orientation value of 6 means the image needs a clockwise rotation for display; it does not mean the pixels have already been rotated. If the underlying pixels are 120 wide and 80 high, the correctly displayed image should be 80 wide and 120 high.

Don’t treat a preview screenshot as proof that the issue is fixed. The preview component may apply the orientation tag while the rest of the pipeline still passes along the original pixels.

First decide which file you need to keep. If you must retain the original, store it separately from the normalized output. If you only need an upload image with a consistent orientation, redraw it before export and decode the exported file to check it again. Don’t simply change the original file’s orientation value to 1: that moves no pixels.

Generate a fixed fixture in an iOS test

Add this test to an iOS unit test target with UIKit enabled. It generates a 120×80-pixel JPEG at runtime and writes an orientation value of 6. It does not depend on a photo from a developer’s computer or commit real images or user information to the repository. The test checks both the tag read by ImageIO and the orientation decoded by UIKit.

import XCTest
import UIKit
import ImageIO
import UniformTypeIdentifiers

final class ImageOrientationTests: XCTestCase {
    func testOrientationSurvivesExport() throws {
        let format = UIGraphicsImageRendererFormat()
        format.scale = 1
        format.opaque = true

        let source = UIGraphicsImageRenderer(
            size: CGSize(width: 120, height: 80),
            format: format
        ).image { _ in
            UIColor.red.setFill()
            UIRectFill(CGRect(x: 0, y: 0, width: 60, height: 80))
            UIColor.blue.setFill()
            UIRectFill(CGRect(x: 60, y: 0, width: 60, height: 80))
        }

        let jpeg = NSMutableData()
        let destination = try XCTUnwrap(
            CGImageDestinationCreateWithData(
                jpeg, UTType.jpeg.identifier as CFString, 1, nil
            )
        )
        CGImageDestinationAddImage(
            destination,
            try XCTUnwrap(source.cgImage),
            [kCGImagePropertyOrientation: 6] as CFDictionary
        )
        XCTAssertTrue(CGImageDestinationFinalize(destination))

        let input = jpeg as Data
        let imageSource = try XCTUnwrap(
            CGImageSourceCreateWithData(input as CFData, nil)
        )
        let properties = try XCTUnwrap(
            CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil)
                as? [CFString: Any]
        )
        XCTAssertEqual(
            (properties[kCGImagePropertyOrientation] as? NSNumber)?.intValue,
            6
        )

        let decoded = try XCTUnwrap(UIImage(data: input))
        XCTAssertEqual(decoded.imageOrientation, .right)
        XCTAssertEqual(decoded.cgImage?.width, 120)
        XCTAssertEqual(decoded.cgImage?.height, 80)

        let upright = UIGraphicsImageRenderer(
            size: CGSize(width: 80, height: 120),
            format: format
        ).image { _ in
            decoded.draw(in: CGRect(x: 0, y: 0, width: 80, height: 120))
        }
        XCTAssertEqual(upright.imageOrientation, .up)

        let exported = try XCTUnwrap(upright.pngData())
        let reopened = try XCTUnwrap(UIImage(data: exported))
        XCTAssertEqual(reopened.imageOrientation, .up)
        XCTAssertEqual(reopened.cgImage?.width, 80)
        XCTAssertEqual(reopened.cgImage?.height, 120)
    }
}

Before running the test, confirm that the Swift file belongs to the test target and select an iOS simulator supported by the project. When running from the command line, supply the project’s actual -workspace, -scheme, and -destination values. Don’t assume an example device name is available on every machine.

Connect the test to the real export path

The test above shows that normalization works; it does not show that application code calls it. Next, put “decode, draw at the target size, encode” in the project’s existing image export function, and have the test call that same function. The input can still be generated at runtime, but assertions should inspect the exported file after reopening it—not the UIImage held before the function returns.

Define a clear size contract for the application function

Start by specifying that the function accepts original file data and returns data with a resolved display orientation. For this fixture with orientation 6, the output pixels should change from 120×80 to 80×120. If the application also resizes images, check the output aspect ratio and exact resized target dimensions instead, and test the resizing rule separately. Don’t collapse rotation, compression, and cropping failures into one assertion.

The two-color image makes it easier to spot mirrored content during a visual check. If an automated test checks where the colors land, use pixel comparisons with a tolerance: lossy JPEG encoding can change colors near the boundary, so exact equality in every channel is inappropriate.

Trace paths that pass preview but fail export

Find the step where the image changes representation: file data, UIImage, CGImage, a drawing context, or a re-encoded file. Record orientation and pixel dimensions at each conversion. This often reveals a branch that extracts cgImage and encodes it directly: cgImage provides the pixels, and the orientation handling performed when drawing a UIImage has not thereby been written into those pixels.

Check the export destination, too. Redrawing creates new pixels and file data, and may change the encoding format, metadata, and file size. If the workflow must preserve other metadata from the original, list the required fields and test each one rather than assuming redrawing copies them unchanged. Ideally, the upload pipeline should decode the final file once more before sending it. An intermediate object that looks right in a viewer is not the deliverable.

Pre-merge checklist

Cover at least an ordinary image with orientation 1, a rotated image with orientation 6, and the mirrored orientations the application actually accepts. For each fixture, assert the input tag, normalized pixel dimensions, and display orientation after decoding the output again. If production code permits cropping, add a non-square fixture: width and height assertions cannot reveal an incorrect rotation of a square image.

Finally, run the tests in the same iOS test target used by the delivery workflow. On failure, record the input orientation, output dimensions, and processing stage—do not write users’ original images to public logs. That way, a regression points to a file-processing step rather than merely reporting that “a photo looks wrong.”

Frequently asked questions

Why can a photo look correct in UIImage but rotate after export?

UIImage can apply orientation metadata while drawing, but the file recipient may interpret it differently. Decode the exported file again and inspect its actual pixel layout.

Can I normalize an image by changing its orientation tag to 1?

No. Changing the tag without rearranging pixels changes how the image appears. Redraw it using its original orientation, then verify the exported result.

Hand your next build to a dedicated cloud Mac.

Compare ViewMini models, regions, and rental terms, then choose a physical node that fits your workflow.

Configure your cloud Mac

Compare devices and rental terms

Choose from three models, with daily, weekly, monthly, and quarterly pricing. Review the full plans before ordering.

View models and pricing Configure your cloud Mac