0

Background: I have images I need to compare for differences. The images are large (on the order of 1400x9000 px), machine-generated and highly constrained (screenshots of a particular piece of linear UI), and are expected to be nearly identical, with differences being one of the following three possibilities:

  • Image 1 has a section image 2 is missing
  • Image 1 is missing a section image 2 has
  • Both images have the given section, but its contents differ

I'm trying to build a tool that highlights the differences for a human reviewer, essentially an image version of line-oriented diff. To that end, I'm trying to scan the images line by line and compare them to decide if the lines are identical. My ultimate goal is an actual diff-like output, where it can detect that sections are missing/added/different, and sync the images up as soon as possible for the remaining parts of identical content, but for the first cut, I'm going with a simpler approach where the two images are overlaid (alpha blended), and the lines which were different highlighted with a particular colour (ie. alpha-blended with a third line of solid colour). At first I tried using Python Imaging Library, but that was far several orders of magnitude too slow, so I decided to try using vips, which should be way faster. However, I have absolutely no idea how to express what I'm after using vips operations. The pseudocode for the simpler version would be essentially:

out = []
# image1 and image2 are expected, but not guaranteed to have the same height
# they are likely to have different heights if different
# most lines are entirely white pixels
for line1, line2 in zip(image1, image2):
    if line1 == line2:
        out.append(line1)
    else:
        # ALL_RED is a line composed of solid red pixels
        out.append(line1.blend(line2, 0.5).blend(ALL_RED, 0.5))

I'm using pyvips in my project, but I'm also interested in code using plain vips or any other bindings, since the operations are shared and easily translated across dialects.

Edit: adding sample images as requestedimage1 image2

Edit 2: full size images with missing/added/changed sections:

mathrick
  • 217
  • 2
  • 11
  • Some representative images would be useful please. – Mark Setchell Oct 22 '19 at 06:35
  • Added some representative samples – mathrick Oct 22 '19 at 06:55
  • Do you really compare Jenkins jobs settings? If so, you should know they are stored in xml files and there are tools to diff xmls. Also I believe ThinBackup plugin can differential backups that also gives a view on what really has changed. – Nakilon Oct 23 '19 at 01:00
  • 1
    I am, and I'm doing it as a part of developing the test suite for a Jenkins job parser, so I'm well aware what jobs are stored as :) – mathrick Oct 23 '19 at 18:53

2 Answers2

1

If OpenCV and NumPy are options to you, then there would be a quite simple solution at least for finding and coloring different rows.

In my approach, I just calculate pixel-wise differences using np.abs, and find non-zero row indices with np.nonzero. With these found row indices, I set up an additional black image and draw red lines for each row. The final blending is just some linear mixing:

0.5 * image1 + 0.5 * image2

for all equal rows, or

0.333 * image1 + 0.333 * image2 + 0.333 * red

for all different rows.

Here's the final code:

import cv2
import numpy as np

# Load images
first = cv2.imread('9gOlq.png', cv2.IMREAD_COLOR)
second = cv2.imread('1Hdx4.png', cv2.IMREAD_COLOR)

# Calcluate absolute differences between images
diff = np.abs(np.float32(first) - np.float32(second))

# Find all non-zero rows
nz_rows = np.unique(np.nonzero(diff)[0])

# Set up image with red lines
red = np.zeros(first.shape, np.uint8)
red[nz_rows, :, :] = [0, 0, 255]

# Set up output image
output = np.uint8(0.5 * first + 0.5 * second)
output[nz_rows, :, :] = 0.333 * first[nz_rows, :, :] + 0.333 * second[nz_rows, :, :] + 0.333 * red[nz_rows, :, :]

# Show results
cv2.imshow("diff", np.array(diff, dtype=np.uint8))
cv2.imshow("output", output)
cv2.waitKey()
cv2.destroyAllWindows()

The difference image diff looks like this:

Difference image

The final output looke like this:

Output

It would be interesting to see two input images with omitted sections as you described in your question. Also, testing this approach using original sized images would be necessary, since you mentioned time is crucial.

Anyway - hope that helps!

HansHirse
  • 18,010
  • 10
  • 38
  • 67
  • Thanks, that is really promising. I linked full-size images with more extensive changes in the question. – mathrick Oct 22 '19 at 16:22
1

How about just using diff? It's pretty quick. All you need to do is turn your PNGs into text a scanline a time, then parse the diff output.

For example:

#!/usr/bin/env python3

import sys
import os
import re
import pyvips

# calculate a checksum for each scanline and write to name_out    
def scanline_checksum(name_in, name_out):
    a = pyvips.Image.new_from_file(name_in, access="sequential")
    # unfold colour channels to make a wider 1-band image
    a = a.bandunfold()
    # xyz makes an index image, where the value of each pixel is its coordinate
    b = pyvips.Image.xyz(a.width, a.height)
    # make a pow gradient image ... each pixel is some power of the x coordinate
    b = b[0] ** 0.5
    # now multiply and sum to make a checksum for each scanline
    # "project" returns sum of columns, sum of rows
    sum_of_columns, sum_of_rows = (a * b).project()
    sum_of_rows.write_to_file(name_out)

to_csv(sys.argv[1], "1.csv")
to_csv(sys.argv[2], "2.csv")

os.system("diff 1.csv 2.csv > diff.csv")

for line in open("diff.csv", "r"):
    match = re.match("(\\d+),(\\d+)c(\\d+),(\\d+)", line)
    if not match:
        continue
    print(line)

For your two test images I see:

$ time ./diff.py 1.png 2.png 
264,272c264,272
351,359c351,359
real    0m0.346s
user    0m0.445s
sys 0m0.033s

On this elderly laptop. All you need to do is use those "change" commands to mark up your images.

jcupitt
  • 10,213
  • 2
  • 23
  • 39
  • That's a really interesting approach. I did look into `.project()`, but I couldn't see how to use it to do anything more than just a plain sum. Would you know how to, say, get a base64 representation of a line? Or anything else with enough structure to be a bijection, so that I could use `difflib` on it, then turn it back into image data? – mathrick Oct 22 '19 at 15:55
  • You could remove the `.project` -- then each row would just be a list of the RGBRGBRGB pixel values. It would be a lot slower though. – jcupitt Oct 22 '19 at 19:06
  • How would I do "something fancier" though? I like your idea of calculating markup first, so most lines can be skipped, but is there a way to express `reduce(lambda x, y: x / 2 + y, pixels_in_line, init=0)` as a Vips operation? Ie. encode them as a sort of "float binary", where each pixel has twice the weight of the previous one. I think that'd always catch lines that differ. – mathrick Oct 22 '19 at 21:10
  • OK, I had a stab at a fancier checksum. This one should spot elements being moved left-right. – jcupitt Oct 22 '19 at 22:25
  • That looks great! Embarrassingly enough, I only just realised that CSV is an output format that Vips can produce, though it's a bit lame it can only write it to a file on disk. It'd be great if it supported generating it in a buffer (or even better, if I could just get the numbers in a buffer). As it is, `bytes(scanline_checksum.write_to_memory())` is *way* slower than writing CSV to a file and reading it back, which is suboptimal, especially since bytes are still a couple slow operations away from being converted to the numbers that Vips actually started with. But I can use the CSV, thanks! – mathrick Oct 23 '19 at 00:24
  • The next version should have CSV read/write to memory, hopefully. You can turn libvips images into Python arrays like this: https://libvips.github.io/pyvips/intro.html#numpy-and-pil – jcupitt Oct 23 '19 at 10:56
  • Ah, that's really nice actually. I ended up doing that, and using `SequenceMatcher` for the diff functionality, and it works very well. – mathrick Oct 24 '19 at 01:48
  • Weirdly enough though, *sometimes* identical lines have different checksums (ie. I can verify they're identical when I compare them after `.extract_area()`), which throws off the matcher. I didn't really have the time to debug it, any idea what might be causing it? ``` (Pdb++) a[i] 46140707.35610962 (Pdb++) b[j] 46120071.029815674 (Pdb++) img1.extract_area(0,i,img1.width,1) (Pdb++) mem1 = img1.extract_area(0,i,img1.width,1).write_to_memory() (Pdb++) mem2 = img2.extract_area(0,j,img2.width,1).write_to_memory() (Pdb++) mem1 == mem2 True ``` – mathrick Oct 24 '19 at 01:51
  • Huh that's very odd. Please open an issue on the libvips tracker with code and images to reproduce this and I'll fix it. https://github.com/libvips/libvips/issues – jcupitt Oct 24 '19 at 02:47