# Copyright (c) 2013 Christopher J Pickett, MIT license, https://github.com/bunchesofdonald/photohash
# Copyright (c) 2013-2016, Johannes Buchner, BSD 2-Clause "Simplified"
# License, https://github.com/JohannesBuchner/imagehash
# Copyright (c) 2019, Ralf Biehl, BSD 2-Clause "Simplified" License, https://gitlab.com/biehl/jscatter
# All rights reserved.

# Redistribution and use in source and binary forms, with or without modification, 
# are permitted provided that the following conditions are met:

# Redistributions of source code must retain the above copyright notice, this list of conditions 
# and the following disclaimer.
# Redistributions in binary form must reproduce the above copyright notice, this list of conditions 
# and the following disclaimer in the documentation and/or other materials provided with the 
# distribution.

# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR 
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY 
# AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


from PIL import Image
import numpy as np
import scipy.fftpack
import os.path
import jscatter as js


class imageHash(object):
    """
    Hash encapsulation. Can be used for dictionary keys and comparisons.
    """

    def __init__(self, image, type=None, hashsize=8, highfreq_factor=4):
        """
        Creates image hash of an image to find duplicates or similar images in a fast way using the Hamming difference.
        
        Implements      
        * average hashing (`aHash`)
        * perception hashing (`pHash`)
        * difference hashing (`dHash`)

        Parameters
        ----------
        image : filename, hexstr, PIL image
            Image to calculate a hash. 
            If a hexstr is given to restore a saved hash it must prepend '0x' and the 0-padded length determines 
            the hash size. type needs to be given additionally.
        type : 'ahash', 'dhash', 'phash'
            Hash type.
        hashsize : int , default 16
            Hash size as hashsize x hashsize array.
        highfreq_factor : int, default=4
            For 'phash' increase initial image size to hashsize*highfreq_factor for cos-transform to
            catch high frequencies.

        Returns imageHash object
            - .bin, .hex, .int return respective representations
            - .similarity(other) returns relative Hamming distance.
            - imageHash subtractions returns Hamming distance.
            - equality checks hashtype and Hamming distance equal zero.

        Notes
        -----
        Images similarity cannot be done by bit comparison (e.g. md5sum) but using a simplified 
        image representation converted to a unique bit representation representing a hash for the image. 

        Similar images should be different only in some bits measured by the Hamming distance 
        (number of different bits).

        A typical procedure is 
         - Reduce color by converting to grayscale.
         - Reduce size to e.g. 8x8 pixels by averaging over blocks.
         - Calc binary pixel hash pased on pixel values:
          - ahash - average hash:  hash[i,j] = pixel > average(pixels)
          - dhash - difference hash: hash[i,j] = pixel[i,j+1] > pixel[i,j] 
          - phash - perceptual hash: 
             The low frequency part of the image cos-transform are most perceptual.
             The cos-transform of the image is used for an average hash.
             hash[i,j] = ahash(cos_tranform(pixels))
          - radial variance: See radon tranform in [1]_ (not implemented)
         - ahash and dhash are faster but phash dicriminates best.
         - Image similarity is decribed by the Hamming difference as number of different bits.
           A good measure is the relative Hamming difference (my similarity) as Hamming_diff/hash.size.
         - Similar images have similarity < 0.1 .
         - Random pixel difference results in  similarity=0.5, an iverted image in similarity =1 (all bits different)


        Examples
        --------
        The calibration image migth be not the best choice as demo or a good one. 
        rotate works not at the center of the beam but for the image center.
        ::

            import jscatter as js
            from jscatter.formel import imageHash
            from PIL import Image
            image = Image.open(js.examples.datapath+'/calibration.tiff')
            type='dhash'
            original_hash = imageHash(image=image, type=type)
            rotate_image = image.rotate(-1) 
            rotate_hash = imageHash(image=rotate_image,type=type)
            sim1 = original_hash.similarity(rotate_hash)
            
            rotate_image = image.rotate(-90)
            rotate_hash = imageHash(image=rotate_image, type=type)
            sim2 = original_hash.similarity(rotate_hash)



        References
        ----------
        .. [1] Rihamark: perceptual image hash benchmarking
               C. Zauner, M. Steinebach, E. Hermann
               Proc. SPIE 7880, Media Watermarking, Security, and Forensics III
               https://doi.org/10.1117/12.876617


        Started based on photohash and imagehash

        Copyright (c) 2013 Christopher J Pickett, MIT license,
        https://github.com/bunchesofdonald/photohash

        Copyright (c) 2013-2016, Johannes Buchner, BSD 2-Clause "Simplified" License,
        https://github.com/JohannesBuchner/imagehash

        Copyright (c) 2019, Ralf Biehl, BSD 2-Clause "Simplified" License, imagehash.py see
        https://gitlab.com/biehl/jscatter/tree/master/src/jscatter/libs

        """

        if hashsize < 2:
            raise ValueError("Hash size must be greater than or equal to 2")

        if isinstance(image, str):
            if image[:2] == '0x':
                # is hex string we convert it to a hash
                size = int(np.trunc((len(image[2:]) * 4) ** 0.5))
                binary = np.array([b == '1' for b in '{:0>{width}b}'.format(int(image, 16), width=size * size)])
                self.hash = binary.reshape(size, -1)
                self.image = None
                self.hashtype = type
                return
            else:
                self.image = Image.open(image)
        else:
            self.image = image

        if type == 'ahash':
            self.hash = self._ahash(hashsize)
            self.hashtype = type
        elif type == 'dhash':
            self.hash = self._ahash(hashsize)
            self.hashtype = type
        else:
            # default type == 'phash':
            self.hash = self._phash(hashsize, highfreq_factor)
            self.hashtype = 'phash'
        return

    def __str__(self):
        return self.hex[2:]

    def __repr__(self):
        return repr(self.hash)

    def __sub__(self, other):
        if other is None:
            raise TypeError('Other hash must not be None.')
        if self.hash.size != other.hash.size:
            raise TypeError('imageHashes must be of the same shape.', self.hash.shape, other.hash.shape)
        if self.hashtype != other.hashtype:
            raise TypeError('imageHashes must be of the same type.', self.type, other.type)
        return np.count_nonzero(self.hash.flatten() != other.hash.flatten())

    def __eq__(self, other):
        if other is None:
            return False
        if self.hashtype != other.hashtype:
            return False
        return np.array_equal(self.hash.flatten(), other.hash.flatten())

    def __ne__(self, other):
        return not self.__eq__(other)

    def __hash__(self):
        # this returns a 8 bit integer, intentionally shortening the information
        return sum([2 ** (i % 8) for i, v in enumerate(self.hash.flatten()) if v])

    def similarity(self, other):
        """
        Relative Hamming difference.

        - Similar <0.1
        - Random pixels are close to 0.5
        - Inverted 1 

        """
        return self.__sub__(other) / (self.shape[0] * self.shape[1])

    @property
    def size(self):
        """
        Size of the hash.
        """
        return self.hash.size

    @property
    def shape(self):
        """
        Shape of the hash.
        """
        return self.hash.shape

    @property
    def bin(self):
        """
        Binary representation
        """
        return '0b' + ''.join(str(b) for b in 1 * self.hash.flatten())

    @property
    def int(self):
        """
        Integer representation
        """
        return int(self.bin, 2)

    @property
    def hex(self):
        """
        Hexadecimal representation
        """
        l = int(np.ceil(self.size // 4))
        hh = hex(self.int)
        return '0x' + hh[2:].rjust(l, '0')

    def _ahash(self, hash_size=8):
        self.image = self.image.convert("L").resize((hash_size, hash_size), Image.ANTIALIAS)

        # find average pixel value; 'pixels' is an array of the pixel values, ranging from 0 (black) to 255 (white)
        pixels = np.asarray(self.image)
        avg = pixels.mean()

        # create string of bits
        diff = pixels > avg
        # make a hash
        return diff

    def _phash(self, hash_size, highfreq_factor):
        img_size = hash_size * highfreq_factor
        self.image = self.image.convert("L").resize((img_size, img_size), Image.ANTIALIAS)

        pixels = np.asarray(self.image)
        dct = scipy.fftpack.dct(scipy.fftpack.dct(pixels, axis=0), axis=1)
        dctlowfreq = dct[:hash_size, :hash_size]
        med = np.median(dctlowfreq)
        diff = dctlowfreq > med
        return diff

    def _dhash(self, hash_size):
        self.image = self.image.convert("L").resize((hash_size + 1, hash_size), Image.ANTIALIAS)

        pixels = np.asarray(self.image)
        # compute differences between columns
        diff = pixels[:, 1:] > pixels[:, :-1]
        return diff


def hex_to_hash(hexstr):
    """
    Convert a stored hash (hex, as retrieved from str(Imagehash))
    back to a Imagehash object.

    Notes:
    1. This algorithm assumes all hashes are bidimensional arrays
       with dimensions hashsize * hashsize.
    2. This algorithm does not work for hashsize < 2.
    """
    hash_size = int(np.sqrt(len(hexstr) * 4))
    binary_array = '{:0>{width}b}'.format(int(hexstr, 16), width=hash_size * hash_size)
    bit_rows = [binary_array[i:i + hash_size] for i in range(0, len(binary_array), hash_size)]
    hash_array = np.array([[bool(int(d)) for d in row] for row in bit_rows])
    return imageHash(hash_array)


import unittest


class TestimageHash(unittest.TestCase):

    def check_hash_algorithm(self, image, type):
        original_hash = imageHash(image=image, type=type)
        rotate_image = image.rotate(-1)
        rotate_hash = imageHash(image=rotate_image, type=type)
        similarity = original_hash.similarity(rotate_hash)
        emsg = ('slightly rotated image should have '
                'similar hash {} {} {}'.format(original_hash, rotate_hash, similarity))
        self.assertTrue(similarity <= 0.1, emsg)
        rotate_image = image.rotate(-90)
        rotate_hash = imageHash(image=rotate_image, type=type)
        emsg = ('rotated image should have different '
                'hash {} {}'.format(original_hash, rotate_hash))
        self.assertNotEqual(original_hash, rotate_hash, emsg)
        similarity = original_hash.similarity(rotate_hash)
        emsg = ('rotated image should have larger different '
                'hash {} {} {}'.format(original_hash, rotate_hash, similarity))
        self.assertTrue(similarity > 0.1, emsg)

    def check_hash_length(self, image, type, sizes=range(2, 21, 1)):
        for hash_size in sizes:
            image_hash = imageHash(image=image, type=type, hashsize=hash_size)
            emsg = 'hashsize={} is not respected'.format(hash_size)
            self.assertEqual(image_hash.hash.size, hash_size ** 2, emsg)

    def check_hash_stored(self, image, type, sizes=range(2, 21, 1)):
        for hash_size in sizes:
            image_hash = imageHash(image=image, type=type, hashsize=hash_size)
            other_hash = imageHash(image_hash.hex, type=image_hash.hashtype)
            emsg = 'hash size {}: stringified hash {} != original hash {}'.format(hash_size,
                                                                                  other_hash, image_hash)
            self.assertEqual(image_hash, other_hash, emsg)
            distance = image_hash - other_hash
            emsg = ('unexpected hamming distance {}: original hash {} '
                    '- stringified hash {} --- hash size {}: '.format(distance, image_hash,
                                                                      other_hash, hash_size))
            self.assertEqual(distance, 0, emsg)

    def check_hash_size(self, image, type, sizes=range(-1, 2)):
        for hash_size in sizes:
            with self.assertRaises(ValueError):
                imageHash(image=image, type=type, hashsize=hash_size)


class allimageHashTest(TestimageHash):
    def setUp(self):
        self.image = Image.open(js.examples.imagepath + '/contourImage.jpg')

    def test_hash(self):
        self.check_hash_algorithm(image=self.image, type='ahash')
        self.check_hash_algorithm(image=self.image, type='dhash')
        self.check_hash_algorithm(image=self.image, type='phash')

    def test_length(self):
        self.check_hash_length(image=self.image, type='ahash')
        self.check_hash_length(image=self.image, type='dhash', sizes=range(2, 21, 3))
        self.check_hash_length(image=self.image, type='phash', sizes=range(2, 21, 3))

    def test_stored(self):
        self.check_hash_stored(image=self.image, type='ahash')
        self.check_hash_stored(image=self.image, type='dhash', sizes=range(2, 21, 3))
        self.check_hash_stored(image=self.image, type='phash', sizes=range(2, 21, 3))

    def test_size(self):
        self.check_hash_size(image=self.image, type='ahash')
        self.check_hash_size(image=self.image, type='dhash')
        self.check_hash_size(image=self.image, type='phash')


def doTest(verbosity=1):
    """Do only one test."""
    loader = unittest.TestLoader()
    suite = unittest.TestSuite()
    suite.addTest(loader.loadTestsFromTestCase(allimageHashTest))
    runner = unittest.TextTestRunner(verbosity=verbosity)
    runner.run(suite)

    return


if __name__ == '__main__':
    unittest.main()
