python-pillow / Pillow

Python Imaging Library (Fork)
https://python-pillow.org
Other
12.07k stars 2.21k forks source link

Add function to compare images #6478

Open Yay295 opened 2 years ago

Yay295 commented 2 years ago

I think it would be useful to have a function to check if two images are the same. I don't mean with a perceptual hash like #3120, or with the existing Image.__eq__ method, but something to check if two images are visually similar. By that I mean the images are the same size and have the same color pixels, after taking into account the EXIF Orientation tag and the images possibly having different modes. Something like

def same_image(img_a,img_b):
    img_a = ImageOps.exif_transpose(img_a)
    img_b = ImageOps.exif_transpose(img_b)
    if img_a.size != img_b.size:
        return False
    img_a = img_a.convert('RGBA')
    img_b = img_b.convert('RGBA')
    diff = ImageChops.difference(img_a,img_b)
    return diff.getbbox() is None and diff.convert('RGB').getbbox() is None

but more efficient. And also it needs to handle images with multiple frames. Or maybe just throw an exception for those for now.

Yay295 commented 2 years ago

This is the fastest I've come up with only using Python code. I'm not sure all of the common_image_modes will work; I just chose values that I think make the most sense for efficiency and not losing data.

edit 2023-04-09: Updated to use ExifTags instead of hardcoding the value, now that ExifTags.Base.Orientation exists, and fixed the last check to correctly handle fully transparent pixels with different pixel values.

edit 2023-10-18: Added support for animated images, added P and PA to the common image mode dict, and added a simple (disabled) debugging function.

edit 2023-11-03: Added support for comparing an animated image with identical frames to an identical single-frame image, and added a check for transparency info.

edit 2023-11-13: Fixed reversed image pixel count check.

import itertools
from PIL import ExifTags, ImageOps, ImageSequence, ImageChops

def apply_exif_orientation(img):
    '''
    ImageOps.exif_transpose() unfortunately makes a copy even if it doesn't do anything,
    so this function checks for the EXIF data first to not make an unnecessary copy.
    However, we do want a copy if a change is made, so we can't just use the "in_place"
    argument for ImageOps.exif_transpose().
    '''
    orientation = img.getexif().get(ExifTags.Base.Orientation)
    if orientation and 2 <= orientation <= 8:
        return ImageOps.exif_transpose(img)
    return img

common_image_mode = {
    '1': {
        '1': '1',
        'L': 'L',
        'LA': 'LA',
        'La': 'La',
        'P': 'RGBA',
        'PA': 'RGBA',
        'RGB': 'RGB',
        'RGBX': 'RGBX',
        'RGBA': 'RGBA',
        'RGBa': 'RGBa',
        'CMYK': 'CMYK',
        'YCbCr': 'YCbCr',
    },
    'L': {
        '1': 'L',
        'L': 'L',
        'LA': 'LA',
        'La': 'La',
        'P': 'RGBA',
        'PA': 'RGBA',
        'RGB': 'RGB',
        'RGBX': 'RGBX',
        'RGBA': 'RGBA',
        'RGBa': 'RGBa',
        'CMYK': 'CMYK',
        'YCbCr': 'YCbCr',
    },
    'LA': {
        '1': '1',
        'L': 'L',
        'LA': 'LA',
        'La': 'La',
        'P': 'RGBA',
        'PA': 'RGBA',
        'RGB': 'RGB',
        'RGBX': 'RGBX',
        'RGBA': 'RGBA',
        'RGBa': 'RGBa',
        'CMYK': 'CMYK',
        'YCbCr': 'YCbCr',
    },
    'La': {
        '1': 'La',
        'L': 'La',
        'LA': 'La',
        'La': 'La',
        'P': 'RGBa',
        'PA': 'RGBa',
        'RGB': 'RGB',
        'RGBX': 'RGBX',
        'RGBA': 'RGBA',
        'RGBa': 'RGBa',
        'CMYK': 'CMYK',
        'YCbCr': 'YCbCr',
    },
    'P': {
        '1': 'RGBA',
        'L': 'RGBA',
        'LA': 'RGBA',
        'La': 'RGBa',
        'P': 'RGBA',
        'PA': 'RGBA',
        'RGB': 'RGBA',
        'RGBX': 'RGBA',
        'RGBA': 'RGBA',
        'RGBa': 'RGBa',
        'CMYK': 'CMYK',
        'YCbCr': 'YCbCr',
    },
    'PA': {
        '1': 'RGBA',
        'L': 'RGBA',
        'LA': 'RGBA',
        'La': 'RGBa',
        'P': 'RGBA',
        'PA': 'RGBA',
        'RGB': 'RGBA',
        'RGBX': 'RGBA',
        'RGBA': 'RGBA',
        'RGBa': 'RGBa',
        'CMYK': 'CMYK',
        'YCbCr': 'YCbCr',
    },
    'RGB': {
        '1': 'RGB',
        'L': 'RGB',
        'LA': 'RGBA',
        'La': 'RGBa',
        'P': 'RGBA',
        'PA': 'RGBA',
        'RGB': 'RGB',
        'RGBX': 'RGBX',
        'RGBA': 'RGBA',
        'RGBa': 'RGBa',
        'CMYK': 'CMYK',
        'YCbCr': 'YCbCr',
    },
    'RGBX': {
        '1': 'RGBX',
        'L': 'RGBX',
        'LA': 'RGBA',
        'La': 'RGBa',
        'P': 'RGBA',
        'PA': 'RGBA',
        'RGB': 'RGB',
        'RGBX': 'RGBX',
        'RGBA': 'RGBA',
        'RGBa': 'RGBa',
        'CMYK': 'CMYK',
        'YCbCr': 'YCbCr',
    },
    'RGBA': {
        '1': 'RGBA',
        'L': 'RGBA',
        'LA': 'RGBA',
        'La': 'RGBa',
        'P': 'RGBA',
        'PA': 'RGBA',
        'RGB': 'RGBA',
        'RGBX': 'RGBA',
        'RGBA': 'RGBA',
        'RGBa': 'RGBa',
        'CMYK': 'RGBA',
        'YCbCr': 'RGBA',
    },
    'RGBa': {
        '1': 'RGBa',
        'L': 'RGBa',
        'LA': 'RGBa',
        'La': 'RGBa',
        'P': 'RGBa',
        'PA': 'RGBa',
        'RGB': 'RGBa',
        'RGBX': 'RGBa',
        'RGBA': 'RGBa',
        'RGBa': 'RGBa',
        'CMYK': 'RGBa',
        'YCbCr': 'RGBa',
    },
    'CMYK': {
        '1': 'CMYK',
        'L': 'CMYK',
        'LA': 'RGBA',
        'La': 'RGBa',
        'P': 'RGBA',
        'PA': 'RGBA',
        'RGB': 'RGB',
        'RGBX': 'RGBX',
        'RGBA': 'RGBA',
        'RGBa': 'RGBa',
        'CMYK': 'CMYK',
        'YCbCr': 'YCbCr',
    },
    'YCbCr': {
        '1': 'YCbCr',
        'L': 'YCbCr',
        'LA': 'RGBA',
        'La': 'RGBa',
        'P': 'RGBA',
        'PA': 'RGBA',
        'RGB': 'RGB',
        'RGBX': 'RGBX',
        'RGBA': 'RGBA',
        'RGBa': 'RGBa',
        'CMYK': 'CMYK',
        'YCbCr': 'YCbCr',
    },
}

def debug(*args):
    #print(*args)
    pass

pixels_to_check = 1000
def same_image(img_a,img_b):
    '''
    Compares two images that might contain multiple frames.
    The given images will not be modified.
    '''

    # If one image is animated but the other isn't.
    # The images could still be the same if the animated image's frames are all the same.
    img_a_is_animated = getattr(img_a,'is_animated',False)
    img_b_is_animated = getattr(img_b,'is_animated',False)
    animation_mismatch = img_a_is_animated ^ img_b_is_animated

    # Check that the images are set to loop the same number of times.
    if not animation_mismatch and getattr(img_a,'loop',None) != getattr(img_b,'loop',None):
        return False

    # These are generators, not tuples.
    img_a_frames = (apply_exif_orientation(frame) for frame in ImageSequence.Iterator(img_a))
    img_b_frames = (apply_exif_orientation(frame) for frame in ImageSequence.Iterator(img_b))

    frame_a = next(img_a_frames,None)
    frame_b = next(img_b_frames,None)

    # Multiple identical frames are the same as one frame if the frames are the
    # same and their total duration is the same. So if we see two frames that
    # are the same but have different durations, we need to check the next
    # frame in the shorter image to see if it's also the same.
    same_frame_duration_a = frame_a.info.get('duration',0)
    same_frame_duration_b = frame_b.info.get('duration',0)

    while True:
        # If we've gone through all of both image's frames,
        # and the duration of the last frame(s) is the same,
        # the images are the same.
        if (frame_a is None) and (frame_b is None):
            return animation_mismatch or same_frame_duration_a == same_frame_duration_b

        # If we've gone through all of one image's frames but not the other, the images aren't the same.
        if (frame_a is None) ^ (frame_b is None):
            debug('- one frame is None')
            return False

        # Check that the frames are the same size.
        if frame_a.size != frame_b.size:
            debug('- frame sizes don\'t match',frame_a.size,frame_b.size)
            return False

        # Make sure the frames are using the same mode.
        try:
            frame_mode = common_image_mode[frame_a.mode][frame_b.mode]
        except KeyError as e:
            raise ValueError(f'cannot compare frames with modes "{frame_a.mode}" and "{frame_b.mode}"')
        if frame_a.mode != frame_mode:
            frame_a = frame_a.convert(frame_mode)
        if frame_b.mode != frame_mode:
            frame_b = frame_b.convert(frame_mode)

        # The frames might have transparency data,
        # and since we just converted both frames to the same mode,
        # the transparency data should be the same.
        frame_a_transparency_info = frame_a.info.get('transparency',None)
        frame_b_transparency_info = frame_b.info.get('transparency',None)
        if frame_a_transparency_info != frame_b_transparency_info:
            debug('- frames have different transparency values',frame_a_transparency_info,frame_b_transparency_info)
            return False

        # Get the index of the alpha channel, or -1.
        # We don't need to check for 'P' or 'PA' because they should have already been converted to something else above.
        if frame_mode in ('LA','La'):
            alpha_channel = 1
        elif frame_mode in ('RGBA','RGBa'):
            alpha_channel = 3
        else:
            alpha_channel = -1

        # Check the first {pixels_to_check} pixels. This can be much faster than checking the entire frame when the frames don't match.
        if alpha_channel != -1:
            # Fully transparent pixels are treated the same regardless of their color data.
            for pixel_a,pixel_b in itertools.islice(zip(frame_a.getdata(),frame_b.getdata()),pixels_to_check):
                if pixel_a != pixel_b and pixel_a[alpha_channel] != 0 and pixel_b[alpha_channel] != 0:
                    debug('- pixels don\'t match',pixel_a,pixel_b)
                    return False
        else:
            for pixel_a,pixel_b in itertools.islice(zip(frame_a.getdata(),frame_b.getdata()),pixels_to_check):
                if pixel_a != pixel_b:
                    debug('- pixels don\'t match',pixel_a,pixel_b)
                    return False

        # dummy loop that we can break out of
        while True:
            # If the image is small enough we may have just checked all of the pixels, so we can stop here.
            # If not, continue checking all of the pixels.
            if frame_a.width * frame_a.height <= pixels_to_check:
                break

            if alpha_channel != -1:
                # Get the difference between the pixels in the frames: abs(a-b).
                diff = ImageChops.difference(frame_a,frame_b)
                # If every channel in every pixel is 0, the frames are the same.
                if diff.getbbox(alpha_only=False) is None:
                    break
                # If not every alpha value of every pixel is 0, the frames aren't the same.
                if diff.getbbox(alpha_only=True) is not None:
                    debug('- diff.getbbox(alpha_only=True) is not None')
                    return False
                # We now know the pixels don't match, but the alpha values do.
                # Get the alpha channel.
                alpha_values = frame_a.getchannel(alpha_channel)
                # Get the max values of the diff of the other channels.
                if frame_mode in ('LA','La'):
                    other_values = diff.getchannel(0)
                elif frame_mode in ('RGBA','RGBa'):
                    other_values = ImageChops.lighter(diff.getchannel(0),ImageChops.lighter(diff.getchannel(1),diff.getchannel(2)))
                # Get the min of the alpha and other channels.
                minmaxalpha = ImageChops.darker(alpha_values,other_values)
                # If either the alpha values are 0 or the diff of the other channels is 0 for every pixel, the frames are the same.
                if minmaxalpha.getbbox() is not None:
                    debug('- minmaxalpha.getbbox() is not None')
                    return False
            else:
                # If there is no alpha channel, we can just compare the frame bytes directly.
                if frame_a.tobytes() != frame_b.tobytes():
                    debug('- bytes don\'t match')
                    return False

            break

        if animation_mismatch:
            if img_a_is_animated:
                frame_a = next(img_a_frames,None)
            if img_b_is_animated:
                frame_b = next(img_b_frames,None)
        elif same_frame_duration_a == same_frame_duration_b:
            frame_a = next(img_a_frames,None)
            frame_b = next(img_b_frames,None)
            if frame_a is not None:
                same_frame_duration_a = frame_a.info.get('duration',0)
            if frame_b is not None:
                same_frame_duration_b = frame_b.info.get('duration',0)
        elif same_frame_duration_a < same_frame_duration_b:
            frame_a = next(img_a_frames,None)
            if frame_a is not None:
                same_frame_duration_a += frame_a.info.get('duration',0)
        else:
            frame_b = next(img_b_frames,None)
            if frame_b is not None:
                same_frame_duration_b += frame_b.info.get('duration',0)
Yay295 commented 2 years ago

Tests/helper.py also has code to convert images to a common type, but it is quite limited:

https://github.com/python-pillow/Pillow/blob/ad7be550aa539dc4c79da928dad071cf0150b731/Tests/helper.py#L55-L65