Skip to content

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

Closed
pimms wants to merge 9 commits into
pointfreeco:mainfrom
pimms:optimization
Closed

Allow for subpixel deviations + improved unoptimized loop execution in image comparison#571
pimms wants to merge 9 commits into
pointfreeco:mainfrom
pimms:optimization

Conversation

@pimms

@pimms pimms commented Feb 9, 2022

Copy link
Copy Markdown

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

bstien commented Feb 11, 2022

Copy link
Copy Markdown

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

Comment thread Sources/SnapshotTesting/Snapshotting/UIImage.swift Outdated
@Deco354

Deco354 commented Mar 30, 2022

Copy link
Copy Markdown

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

@Deco354

Deco354 commented Mar 31, 2022

Copy link
Copy Markdown

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
Copy Markdown

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).

public static func image(
drawHierarchyInKeyWindow: Bool = false,
precision: Float = 1,
subpixelThreshold: UInt8 = 0,

Copy link
Copy Markdown

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
Copy Markdown
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
Copy Markdown

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.

@Deco354 Deco354 left a comment

Copy link
Copy Markdown

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

pimms commented May 23, 2022

Copy link
Copy Markdown
Author

@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

Deco354 commented May 26, 2022

Copy link
Copy Markdown

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

Copy link
Copy Markdown

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.

Copy link
Copy Markdown

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

Copy link
Copy Markdown

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

katzenbaer commented Jun 10, 2022

Copy link
Copy Markdown

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.

@stephencelis stephencelis left a comment

Copy link
Copy Markdown
Member

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

pimms commented Jun 13, 2022

Copy link
Copy Markdown
Author

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

chosa91 commented Jun 15, 2022

Copy link
Copy Markdown

@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

pimms commented Jun 15, 2022

Copy link
Copy Markdown
Author

@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

chosa91 commented Jun 16, 2022

Copy link
Copy Markdown

@cameroncooke

Copy link
Copy Markdown

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

@CraigSiemens

CraigSiemens commented Jul 18, 2022

Copy link
Copy Markdown
Contributor

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
Copy Markdown

Is there a schedule when this will be merged?

@reejosamuel

Copy link
Copy Markdown

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
Copy Markdown

@stephencelis any plans to merge it?

@Kaspik

Kaspik commented Aug 25, 2022

Copy link
Copy Markdown

@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
Copy Markdown
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.