comictagger/comictaggerlib/imagehasher.py
2024-02-10 15:02:24 -08:00

185 lines
6.0 KiB
Python

"""A class to manage creating image content hashes, and calculate hamming distances"""
#
# Copyright 2013 ComicTagger Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import io
import itertools
import logging
import math
from collections.abc import Sequence
from statistics import median
from typing import TypeVar
try:
from PIL import Image
pil_available = True
except ImportError:
pil_available = False
logger = logging.getLogger(__name__)
class ImageHasher:
def __init__(self, path: str | None = None, data: bytes = b"", width: int = 8, height: int = 8) -> None:
self.width = width
self.height = height
if path is None and not data:
raise OSError
try:
if path is not None:
self.image = Image.open(path)
else:
self.image = Image.open(io.BytesIO(data))
except Exception:
logger.exception("Image data seems corrupted!")
# just generate a bogus image
self.image = Image.new("L", (1, 1))
def average_hash(self) -> int:
try:
image = self.image.resize((self.width, self.height), Image.Resampling.LANCZOS).convert("L")
except Exception:
logger.exception("average_hash error")
return 0
pixels = list(image.getdata())
avg = sum(pixels) / len(pixels)
diff = "".join(str(int(p > avg)) for p in pixels)
result = int(diff, 2)
return result
def average_hash2(self) -> None:
"""
# Got this one from somewhere on the net. Not a clue how the 'convolve2d' works!
from numpy import array
from scipy.signal import convolve2d
im = self.image.resize((self.width, self.height), Image.ANTIALIAS).convert('L')
in_data = array((im.getdata())).reshape(self.width, self.height)
filt = array([[0,1,0],[1,-4,1],[0,1,0]])
filt_data = convolve2d(in_data,filt,mode='same',boundary='symm').flatten()
result = reduce(lambda x, (y, z): x | (z << y),
enumerate(map(lambda i: 0 if i < 0 else 1, filt_data)),
0)
return result
"""
def p_hash(self) -> int:
"""
Pure python version of Perceptual Hash computation of https://github.com/JohannesBuchner/imagehash/tree/master
Implementation follows http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html
"""
def generate_dct2(block: Sequence[Sequence[float]], axis: int = 0) -> list[list[float]]:
def dct1(block: Sequence[float]) -> list[float]:
"""Perform 1D Discrete Cosine Transform (DCT) on a given block."""
N = len(block)
dct_block = [0.0] * N
for k in range(N):
sum_val = 0.0
for n in range(N):
cos_val = math.cos(math.pi * k * (2 * n + 1) / (2 * N))
sum_val += block[n] * cos_val
dct_block[k] = sum_val
return dct_block
"""Perform 2D Discrete Cosine Transform (DCT) on a given block along the specified axis."""
rows = len(block)
cols = len(block[0])
dct_block = [[0.0] * cols for _ in range(rows)]
if axis == 0:
# Apply 1D DCT on each row
for i in range(rows):
dct_block[i] = dct1(block[i])
elif axis == 1:
# Apply 1D DCT on each column
for j in range(cols):
column = [block[i][j] for i in range(rows)]
dct_column = dct1(column)
for i in range(rows):
dct_block[i][j] = dct_column[i]
else:
raise ValueError("Invalid axis value. Must be either 0 or 1.")
return dct_block
def convert_image_to_ndarray(image: Image.Image) -> Sequence[Sequence[float]]:
width, height = image.size
pixels2 = []
for y in range(height):
row = []
for x in range(width):
pixel = image.getpixel((x, y))
row.append(pixel)
pixels2.append(row)
return pixels2
highfreq_factor = 4
img_size = 8 * highfreq_factor
try:
image = self.image.convert("L").resize((img_size, img_size), Image.Resampling.LANCZOS)
except Exception:
logger.exception("p_hash error converting to greyscale and resizing")
return 0
pixels = convert_image_to_ndarray(image)
dct = generate_dct2(generate_dct2(pixels, axis=0), axis=1)
dctlowfreq = list(itertools.chain.from_iterable(row[:8] for row in dct[:8]))
med = median(dctlowfreq)
# Convert to a bit string
diff = "".join(str(int(item > med)) for item in dctlowfreq)
result = int(diff, 2)
return result
# accepts 2 hashes (longs or hex strings) and returns the hamming distance
T = TypeVar("T", int, str)
@staticmethod
def hamming_distance(h1: T, h2: T) -> int:
if isinstance(h1, int):
n1 = h1
else:
n1 = int(h1, 16)
if isinstance(h2, int):
n2 = h2
else:
n2 = int(h2, 16)
# xor the two numbers
n = n1 ^ n2
# count up the 1's in the binary string
return sum(b == "1" for b in bin(n)[2:])