UIImage not equivalent when encoding/decoding
Asked Answered
I

1

6

I've been doing some tests on my models to make sure they are equal when I encode them into JSON and then decode them back using JSONEncoder/Decoder. However, one of my tests failed, and the culprit was UIImage. I've made sure that no errors were thrown during the encoding/decoding process.

First of all, this is the test in question:

func testProfileImageCodable() throws {
    let image = ProfileImage(UIImage(systemName: "applelogo")!)
    try XCTAssertTrue(assertCodable(image))
}

Here's my "Codability" test, where I make sure that types are equal before and after encoding/decoding:

func assertCodable<T: Codable & Equatable>(
    _ value: T,
    decoder: JSONDecoder = .init(),
    encoder: JSONEncoder = .init()
) throws -> Bool {
    let encoded = try encoder.encode(value)
    let decoded = try decoder.decode(T.self, from: encoded)
    
    return value == decoded
}

Firstly, here's how I made UIImage work with Codable:

extension KeyedEncodingContainer {
    mutating func encode(_ value: UIImage, forKey key: Key) throws {
        guard let data = value.pngData() else {
            throw EncodingError.invalidValue(
                value,
                EncodingError.Context(codingPath: [key],
                debugDescription: "Failed convert UIImage to data")
            )
        }
        try encode(data, forKey: key)
    }
}

extension KeyedDecodingContainer {
    func decode(_ type: UIImage.Type, forKey key: Key) throws -> UIImage {
        let imageData = try decode(Data.self, forKey: key)
        if let image = UIImage(data: imageData) {
            return image
        } else {
            throw DecodingError.dataCorrupted(
                DecodingError.Context(codingPath: [key],
                debugDescription: "Failed load UIImage from decoded data")
            )
        }
    }
}

The UIImage lives in a ProfileImage type, so conforming it to Codable looks like this:

extension ProfileImage: Codable {
    enum CodingKeys: CodingKey {
        case image
    }
    
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.image = try container.decode(UIImage.self, forKey: .image)
    }
    
    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(self.image, forKey: .image)
    }
}

Furthermore, ProfileImage's Equatable conformance uses isEqual(_:) on the UIImage property, which they say is "the only reliable way to determine whether two images contain the same image data."

Yet, my test still fails, and I'm not sure why. Any help would be greatly appreciated.

Izard answered 25/6, 2021 at 21:52 Comment(0)
D
7

the only reliable way to determine whether two images contain the same image data

They are wrong about that. That piece of the docs has misled me in the past too!

The way to compare two images for equality of content (the underlying bitmap) is to compare their pngData.


What's wrong with your code, however, at the deepest level, is that a UIImage has scale information which you are throwing away. For example, your original image's scale is probably 2 or 3. But when you call image(data:) on decoding, you fail to take that into account. If you did take it into account, your assertion would work as you expect.

I tweaked your code like this (there might be a better way, I just wanted to prove that scale was the issue):

struct Image: Codable {
    let image:UIImage
    init(image:UIImage) {
        self.image = image
    }
    enum CodingKeys: CodingKey {
        case image
        case scale
    }
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let scale = try container.decode(CGFloat.self, forKey: .scale)
        let image = try container.decode(UIImage.self, forKey: .image)
        self.image = UIImage(data:image.pngData()!, scale:scale)!
    }
    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(self.image, forKey: .image)
        try container.encode(self.image.scale, forKey: .scale)
    }
}

Here's my test:

let im = UIImage(systemName:"applelogo")!
let encoded = try! JSONEncoder().encode(Image(image:im))
let decoded = try! JSONDecoder().decode(Image.self, from: encoded)
assert(im.pngData()! == decoded.image.pngData()!)
print("ok") // yep
Definitive answered 25/6, 2021 at 21:58 Comment(19)
I see. Yet, it still doesn't work! I changed my test to this: func testProfileImageCodable() throws { let testImage = UIImage(systemName: "applelogo")! let encoded = try JSONEncoder().encode(ProfileImage(testImage)) let decoded = try JSONDecoder().decode(ProfileImage.self, from: encoded) XCTAssertTrue(decoded.image.pngData() == testImage.pngData()) } and it still fails. Maybe it's a problem with how I implemented UIImage's encoding/decoding? (sorry for the formatting, not being to add full code to SO comments is a pain)Izard
Here's a gist with the relevant code if that's easier for you to read.Izard
Yeah, I see what's wrong, you've thrown away the scale information.Definitive
I see, great catch! The scale information is actually something I most likely don't want to keep, since the UIImage will be displayed multiple times in different sizes throughout the app and in different screen sizes (so I'll let SwiftUI decide at what scale the image should be shown). However, what I will do is create a wrapper struct just for the testing where I will keep the scale information, so that my tests can pass. Thanks a lot!Izard
Oh, rewrite the tests to mask the bug? Good idea. Not. :)Definitive
I edited to get rid of the extra property. But to get rid of the scale information entirely would be wrong; they will not be same image and your tests must not pretend they are.Definitive
Doesn't the scale default to 1.0 though? They won't be images loaded into the Assets folder, they'll be coming straight from the user's photo library.Izard
But that's not what your test tests. You started with UIImage(systemName:"applelogo")!. That UIImage does not have scale 1.0.Definitive
Moreover, I do not know for a fact that a UIImage obtained from the user's photo library has scale 1.0. Do you?Definitive
Yes you're right, the UIImage(systemName: "applelogo")! is just a placeholder, I'm going to be bundling a mock image with my test package. The documentation says that "If you load an image from a file whose name includes the @2x modifier, the scale is set to 2.0. You can also specify an explicit scale factor when initializing an image from a Core Graphics image. All other images are assumed to have a scale factor of 1.0."Izard
I therefore assume that a UIImage from a user's photo library has scale 1.0 – but maybe it differs between devices, just like in the Asset.xcassets. For completeness' sake, I think you're right – I'll be encoding/decoding the scale property too.Izard
Again, do not adjust the test to how you think your code will or will not be used. Either your code can round trip an image — any image — through coding / decoding or it cannot. Otherwise your use of testing is invalid.Definitive
Am I missing something? The problem is that UIImage requires to conform to Codable, right? But in this line try container.decode(UIImage.self, forKey: .image) you are passing it to decoder as-is... So how do you solve the Codable issue?Reed
Should not it be like this - let image: Data = try container.decode(Data.self, forKey: .image) ?Reed
The answer doesn't compile, whats going on? Can you please update? @DefinitiveFigurine
@Figurine Compiles fine for me, what's the issue for you?Definitive
let image = try container.decode(UIImage.self, forKey: .image), UIImage does not conform to codableFigurine
@Figurine Because you forgot to include the OP's code. This question is about the OP's code. My answer is merely an addition to it. The OP's code is what makes UIImage conform to Codable. My answer merely makes the OP's round-tripping test succeed.Definitive
I see now that @Reed made the same mistake. It really helps to read the question and the answer.Definitive

© 2022 - 2024 — McMap. All rights reserved.