Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow for subpixel deviations + improved unoptimized loop execution in image comparison #571

Closed
wants to merge 9 commits into from

Conversation

pimms
Copy link

@pimms pimms commented Feb 9, 2022

Subpixel difference

Workaround for the issue described in #313 and #424.

The readme clearly states that the reference-device should be identical to the recorder-device, but as M1 Macs are becoming popular with developers at a different rate to CI & cloud providers, that is becoming increasingly difficult.

Allow each subpixel (R, G, B) to deviate by up to a certain degree. Currently, a precision: 0.9 means that "90% of the pixels must match 100%". The issues described in particularly #313, where some experience that the pixels deviate by only 1, can be resolved by comparing with subpixelThreshold: 1. Comparing with precision: 0.9, subpixelThreshold: 1 then means "90% of the subpixels' value must be ±1 of the reference".

Optimization

When an image comparison memcmp fails and we need to compare each byte of the failing image, the current loop iteration is slow when compiled without optimization on Intel machines.

My team defaulted to adding custom compiler flags to SnapshotTesting, but the problem can also be diminished significantly by using a more basic loop implementation.

The difference between looping over a range (current implementation) and looping with a condition can be seen on Godbolt.

Example in Godbolt
  1. Go to godbolt.org
  2. Change language to Swift
  3. Copy the following code in the left-hand editor
  4. Wait for the compiler
  5. Compare sections LBB1_4-LBB1_10 with LBB3_1
func byteDiff1(reference: [UInt8], comparison: [UInt8]) -> Bool {
    let maxDiffs = 100
    var diffs = 0
    for byte in 0..<reference.count {
        if reference[byte] != comparison[byte] {
            diffs += 1
            if diffs >= maxDiffs {
                return false
            }
        }
    }
    return true
}

func byteDiff2(reference: [UInt8], comparison: [UInt8]) -> Bool {
    let maxDiffs = 100
    var diffs = 0
    var byte = 0
    while byte < reference.count {
        if reference[byte] != comparison[byte] {
            diffs += 1
            if diffs >= maxDiffs {
                return false
            }
        }
        byte += 1
    }
    return true
}

@pimms pimms changed the title Improved unoptimized loop execution in image comparison Allow for subpixel deviations + improved unoptimized loop execution in image comparison Feb 9, 2022
@bstien
Copy link

bstien commented Feb 11, 2022

This is an great fix for a weird problem, and I just want to voice my support in the hope that this PR gets merged.

We have the same issue with differences in produced colors on M1 and Intel and this will solve our issue until the whole team (and possibly CircleCI) is on M1. Lowering the precision threshold is not an option for us, since we primarily use this library to test placement and size of elements. Minor differences in colors, however, is something we can excuse since we now need to have a hybrid M1/Intel solution in our team.

I've opened PRs in our component libraries that will use @pimms fork until this (hopefully) gets merged.

finn-no/FinniversKit#1094
finn-no/finnui-ios#66

@Deco354
Copy link

Deco354 commented Mar 30, 2022

@dmitri-zganiaiko Nice find on the threshhold calculation I think @pimms has addressed it and this is ready for review again.

@Deco354
Copy link

Deco354 commented Mar 31, 2022

This is still blocked I think we need an approval from someone with write access.
@stephencelis @mbrandonw are you able to help with this?

@choulepoka
Copy link

Just to emphasize the important of this PR, we have a team that begins to use M1 as the main development platform, but our CI will be on Intel for the foreseeable future (At least until Github provides M1-Based Github Runners).

/// - size: A view size override.
/// - traits: A trait collection override.
public static func image(
drawHierarchyInKeyWindow: Bool = false,
precision: Float = 1,
subpixelThreshold: UInt8 = 0,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it maybe make sense the default the threshold to a value which will avoid the M1 vs Intel issue out of the box (ex - 5)?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't seen any "one" value that would make sense. Sometimes, with corner-rounding for instance, the difference is <= 2, while with images it seems to typically be <= 1. With shadows and rounded corners, it may be as high as <= 5. I think all of these values are anecdotal though, and that the user should have a fully explicit relationship with this value. After all, this is a hacky workaround, and it's probably a good idea to not enable it by default.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YeH, makes sense, I was just hoping there was a magic value haha.

Copy link

@Deco354 Deco354 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pimms Thanks for sharing this I’m using it now and it works great.

I’ve a few nits that don't really matter but on the off-chance this delays the eventual merge I'm happy to do myself if you're ok with it as they appear to be pretty trivial.

I wasn't able to check that the internal snapshot tests for this project work, there are a fair few failing but I suspect this is due to me not knowing what simulator to use or the tests already failing.

@pimms
Copy link
Author

pimms commented May 23, 2022

@Deco354 Thanks, good points. I believe that a handful of tests are failing on main, but I may still have broken some more.

I don't think I'll have time to look at this this week at least, so if you or someone else wants to have a look at it it'd be great — otherwise I'll get to it at some point.

@Deco354
Copy link

Deco354 commented May 26, 2022

One more thing, what exactly does 1 byte of the pixelDensityThreshold equate to? I'm not very familiar with the ins and outs of Image diffing. i.e. What value would this need to be set to for all possible pixel colors to be deemed equal?

Comment on lines +87 to +94
while offset < pixelCount * 4 {
if p1[offset].diff(between: p2[offset]) > subpixelThreshold {
differentPixelCount += 1
if differentPixelCount > threshold {
return false
}
}
if Float(differentPixelCount) > threshold { return false }
offset += 1

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason this was changed from a for-loop to a while-loop? My interpretation is that in both implementations the loop should either increment offset or return false.

Using a while loop and manually incrementing the offset makes this less maintainable since it's easy to introduce changes in the loop body that skip incrementing offset and would lead to an infinite loop.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind, just saw your PR description with the explanation for when compiled without optimization.

Comment on lines +109 to +116
while byte < byteCount {
if oldBytes[byte].diff(between: newerBytes[byte]) > subpixelThreshold {
differentPixelCount += 1
if differentPixelCount >= threshold {
return false
}
}
byte += 1

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here about replacing the for loop with a while loop.

@katzenbaer
Copy link

katzenbaer commented Jun 10, 2022

One more thing, what exactly does 1 byte of the pixelDensityThreshold equate to? I'm not very familiar with the ins and outs of Image diffing. i.e. What value would this need to be set to for all possible pixel colors to be deemed equal?

I believe it's using the UInt8 value representation of RGB. So a threshold value of 255 would mean that pixels would always match regardless of their color.

Copy link
Member

@stephencelis stephencelis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pimms Thanks for taking this on! We've been backed up on some of these PRs, but are hoping to merge them slowly.

I think we'll likely merge this as is unless you want to tie up some of the loose ends mentioned above, first. Once we merge we may also make a few small changes on main as far as API naming goes.

Let us know what you think!

katzenbaer added a commit to katzenbaer/swift-snapshot-testing that referenced this pull request Jun 11, 2022
@pimms
Copy link
Author

pimms commented Jun 13, 2022

Great news, @stephencelis! 🎉 I've not had as much free time to look into the follow-ups as I thought I'd have, and I know I don't have much time coming up. If you're fine with merging this as-is, that sounds great 💯

@chosa91
Copy link

chosa91 commented Jun 15, 2022

@pimms Can you tell us, what flags are used?

My team defaulted to adding custom compiler flags to SnapshotTesting, but the problem can also be diminished significantly by using a more basic loop implementation.

@pimms
Copy link
Author

pimms commented Jun 15, 2022

@chosa91 We were using CocoaPods at the time, and added the following at the end of our Podfile. I can't fully recall what Xcode-values this would translate to.

post_install do |installer|
    installer.pods_project.targets.each do |target|
        if target.name == "SnapshotTesting"
            target.build_configurations.each do |config|
                xcconfig_path = config.base_configuration_reference.real_path
                puts "Setting optimization flags on #{xcconfig_path}..."
                File.open(xcconfig_path, "a") {|file|
                  file.puts 'SWIFT_OPTIMIZATION_LEVEL = -O'
                  file.puts 'GCC_OPTIMIZATION_LEVEL = 3'
                }
            end
        end
    end
end

@chosa91
Copy link

chosa91 commented Jun 16, 2022

@cameroncooke
Copy link

Will this be merged soon, would be good to be able to start testing this?

@CraigSiemens
Copy link

CraigSiemens commented Jul 18, 2022

We started using this fork in our project and it's been working great so far. Setting subpixelThreshold: 1 was enough to handle the differences in shadow rendering across different architectures for most of our failing tests.

@afanaian01
Copy link

Is there a schedule when this will be merged?

@reejosamuel
Copy link

Great news, @stephencelis! 🎉 I've not had as much free time to look into the follow-ups as I thought I'd have, and I know I don't have much time coming up. If you're fine with merging this as-is, that sounds great 💯

@stephencelis any thoughts on merging this?

@sschizas
Copy link

@stephencelis any plans to merge it?

@Kaspik
Copy link

Kaspik commented Aug 25, 2022

@mbrandonw Trying to ping someone else from this org to take a look and merge it 🙏🏼

Edit: Seems like perceptualPrecision is probably better solution?

@stephencelis
Copy link
Member

Thanks everyone in the community for their contributions towards landing this, and their patience! We've gone ahead and merged #628, which supersedes this one, so I'm going to close this out.

Thanks again @pimms for the PR, and if you believe #628 is missing anything, please let us know!

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.