3
\$\begingroup\$

getFinalImageData(:UIImage) takes a UIImage and downscales its size dimensions (to 400 points, in this example) and returns it as Data that has been compressed to be within the byte limit (using the two UIImage extensions). This function works great but I would love to get some other eyes on it.

typealias Bytes = Int64

extension Bytes {
    static let KB300: Int64 = 300_000
    static let MB1: Int64 = 1_048_576
    static let MB2: Int64 = 2_097_152
    static let MB80: Int64 = 83_886_080
    static let MB100: Int64 = 104_857_600
    static let MB120: Int64 = 125_829_120
}

extension UIImage {
    func compressedToJPEGData(maxBytes limit: Int64) -> Data? {
        /// These are compression multipliers (1 = least compression,
        /// 0 = most compression) to define the granularity of
        /// compression reduction.
        let multipliers: [CGFloat] = [1, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0]
        
        for m in multipliers {
            if let data = jpegData(compressionQuality: m),
               data.count <= limit {
                return data
            }
        }
        return nil
    }
    
    func downscaled(maxPoints limit: CGFloat) -> UIImage {
        let width = size.width
        let height = size.height
        let maxLength = max(width, height)
        
        guard maxLength > limit else {
            return self
        }
        let downscaleDivisor = maxLength / limit
        let downscaledDimensions = CGSize(width: width / downscaleDivisor,
                                          height: height / downscaleDivisor)
        
        return UIGraphicsImageRenderer(size: downscaledDimensions).image { (_) in
            draw(in: CGRect(origin: .zero, size: downscaledDimensions))
        }
    }
}

func getFinalImageData(from image: UIImage) -> Data? {
    let downscaled = image.downscaled(maxPoints: 400)

    if let compressed = downscaled.compressedToJPEGData(maxBytes: Bytes.KB300) {
        return compressed
    }
    return nil
}
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

A couple of quick observations:

  1. In UIGraphicsImageRenderer, make sure to set the scale in the UIGraphicsImageRendererFormat to be the scale of the image. Usually it is 1.0, unless it is a screen snapshot, in which case it is the scale of the device that it was snapshotted from. Bottom line, UIGraphicsImageRenderer will default to the scale of the device, which may make your image much bigger than you intended. E.g., take a 400×400 px image, and the renderer on a 3× device will make it 1200×1200 with no additional data, which is the exact opposite of what you obviously intend. The correct scale is a function of the image in question, not the device.

  2. Do you have access to the original Data associated with this asset? (Note, I am not asking about the output of pngData or jpegData, but the raw data of the original asset.) E.g., a photo taken with a camera generally has decent JPEG compression with it already, and round-tripping it through UIImage and then adding JPEG compression like 0.9 can actually simultaneously lose data and make it bigger. Bottom line, make the decision to downscale/compress only if the original raw asset demands it.

  3. Once you decide that the original asset is really too big, on the array of JPEG compression rates, you should probably remove the 1.0 scale from the list, as that will make the asset huge with absolutely no image improvement. I think 0.8 is a fine starting point. 0.9 if you want to be conservative. Try it out and you will see what I mean.

    Bottom line 1.0 compression makes it much bigger. 0.7-0.8 results in barely visible JPEG artifacts, and it falls apart quickly below 0.6, IMHO.


Below you mention that you are using UIImagePickerController. If so, consider the following:


let formatter: NumberFormatter = {
    let formatter = NumberFormatter()
    formatter.numberStyle = .decimal
    return formatter
}()

extension ViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        picker.dismiss(animated: true)

        guard let asset = info[.phAsset] as? PHAsset else { return }

        asset.requestContentEditingInput(with: nil) { [self] input, info in
            guard
                let fileURL = input?.fullSizeImageURL,
                let data = try? Data(contentsOf: fileURL),
                let image = UIImage(data: data),
                let data1 = image.jpegData(compressionQuality: 1),
                let data9 = image.jpegData(compressionQuality: 0.9),
                let data8 = image.jpegData(compressionQuality: 0.8),
                let data7 = image.jpegData(compressionQuality: 0.7),
                let data6 = image.jpegData(compressionQuality: 0.6)
            else { return }

            print("original", formatter.string(for: data.count)!) // 2,227,880

            print(1.0, formatter.string(for: data1.count)!)       // 6,242,371
            print(0.9, formatter.string(for: data9.count)!)       // 3,672,570
            print(0.8, formatter.string(for: data8.count)!)       // 3,004,577
            print(0.7, formatter.string(for: data7.count)!)       // 2,576,892
            print(0.6, formatter.string(for: data6.count)!)       // 1,958,503
        }
    }
}

I am obviously not suggesting the above implementation in your code, but rather merely illustrating that (a) one way you can fetch the original asset; and (b) to show how its size compares to jpegData of various compression quality settings after round-tripping to a UIImage. Obviously, my numbers are from a random image in my photo library, and your values will vary, but the above sizes are entirely consistent with my historical experiments with various compression settings for JPEGs.

Perhaps needless to say, if accessing the photos library, you must request permission:

PHPhotoLibrary.requestAuthorization { granted in
    print(granted)
}

And set NSPhotoLibraryUsageDescription in the Info.plist.

\$\endgroup\$
2
  • \$\begingroup\$ Regarding point 1, I think that's sound advice and I took it. Regarding point 2, in its current usage, the image will always be imported from an iPhone Photo Library using UIImagePickerController; there is no support for direct-from-camera or importing images otherwise. What do you suggest here? Regarding point 3, I was under the assumption that a scale of 1 was equal to doing nothing. And so if the image is already within the byte limit, do nothing. Is this not the case? \$\endgroup\$
    – slushy
    Commented Aug 21, 2022 at 1:37
  • \$\begingroup\$ Re 2, when using UIImagePickerController, you can fetch the original asset URL. See updated answer for one example. Re 3, in my code snippet I take 2.2 mb asset, make UIImage and then look at the resulting jpegData with compression values of 1.0, 0.9, 0.8, 0.7, and 0.6, and you can see that it didn't end up being smaller than the original asset until I got below 0.7. Your mileage may vary. \$\endgroup\$
    – Rob
    Commented Aug 21, 2022 at 20:22

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.