from __future__ import annotations
import os
from io import BytesIO
import numpy as np
from PIL import Image
from skimage.color import rgb2gray
from skimage.io import imread
try:
from cairosvg import svg2png
except ImportError:
svg2png = None
# accepted inputs anywhere an image can be loaded from
type ImageInput = str | os.PathLike[str] | bytes | np.ndarray
[docs]
class CorruptImageError(RuntimeError):
"""Raised when image data cannot be decoded."""
def _diagonal_differences(matrix: np.ndarray, diagonals: np.ndarray) -> np.ndarray:
"""Differences along each diagonal of a square matrix, reassembled into
a matrix the same shape as the input.
"""
return np.sum([np.diagflat(np.insert(np.diff(np.diag(matrix, i)), 0, 0), i) for i in diagonals], axis=0)
[docs]
class ImageSignature:
"""Image signature generator.
Based on the method of Goldberg, et al. Available at http://www.cs.cmu.edu/~hcwong/Pdfs/icip02.ps
"""
[docs]
def __init__(
self,
n: int = 9,
crop_percentiles: tuple[int, int] | None = (5, 95),
P: int | None = None,
diagonal_neighbors: bool = True,
identical_tolerance: float = 2 / 255.0,
n_levels: int = 2,
fix_ratio: bool = False,
) -> None:
"""Initialize the signature generator.
The default parameters match those given in Goldberg's paper.
Note:
Non-default parameters have not been extensively tested. Use carefully.
Args:
n (Optional[int]): size of grid imposed on image. Grid is n x n (default 9)
crop_percentiles (Optional[Tuple[int]]): lower and upper bounds when considering how much
variance to keep in the image (default (5, 95))
P (Optional[int]): size of sample region, P x P. If none, uses a sample region based
on the size of the image (default None)
diagonal_neighbors (Optional[boolean]): whether to include diagonal grid neighbors
(default True)
identical_tolerance (Optional[float]): cutoff difference for declaring two adjacent
grid points identical (default 2/255)
n_levels (Optional[int]): number of positive and negative groups to stratify neighbor
differences into. n = 2 -> [-2, -1, 0, 1, 2] (default 2)
fix_ratio (Optional[boolean]): use the larger crop ratio for both directions. Only use
if you can guarantee the incoming image is square (default False)
"""
# check inputs
assert crop_percentiles is None or len(crop_percentiles) == 2, "crop_percentiles should be a two-value tuple, or None"
if crop_percentiles is not None:
assert crop_percentiles[0] >= 0, f"Lower crop_percentiles limit should be > 0 ({crop_percentiles[0]!r} given)"
assert crop_percentiles[1] <= 100, f"Upper crop_percentiles limit should be < 100 ({crop_percentiles[1]!r} given)"
assert crop_percentiles[0] < crop_percentiles[1], "Upper crop_percentile limit should be greater than lower limit."
self.lower_percentile = crop_percentiles[0]
self.upper_percentile = crop_percentiles[1]
self.crop_percentiles = crop_percentiles
else:
self.crop_percentiles = crop_percentiles
self.lower_percentile = 0
self.upper_percentile = 100
assert type(n) is int, "n should be an integer > 1"
assert n > 1, f"n should be greater than 1 ({n!r} given)"
self.n = n
assert type(P) is int or P is None, "P should be an integer >= 1, or None"
if P is not None:
assert P >= 1, f"P should be greater than 0 ({P!r} given)"
self.P = P
assert type(diagonal_neighbors) is bool, "diagonal_neighbors should be boolean"
self.diagonal_neighbors = diagonal_neighbors
self.sig_length = self.n**2 * (4 + self.diagonal_neighbors * 4)
assert type(fix_ratio) is bool, "fix_ratio should be boolean"
self.fix_ratio = fix_ratio
assert type(identical_tolerance) is float or type(identical_tolerance) is int, "identical_tolerance should be a number between 1 and 0"
assert 0.0 <= identical_tolerance <= 1.0, f"identical_tolerance should be greater than zero and less than one ({identical_tolerance!r} given)"
self.identical_tolerance = identical_tolerance
assert type(n_levels) is int, "n_levels should be an integer"
assert n_levels > 0, f"n_levels should be > 0 ({n_levels!r} given)"
self.n_levels = n_levels
self.handle_mpo = True
[docs]
def generate_signature(self, path_or_image: ImageInput, bytestream: bool = False) -> np.ndarray:
"""Generates an image signature.
See section 3 of Goldberg, et al.
Args:
path_or_image (string or numpy.ndarray): image path, or image array
bytestream (Optional[boolean]): will the image be passed as raw bytes?
That is, is the 'path_or_image' argument an in-memory image?
(default False)
Returns:
The image signature: A rank 1 numpy array of length n x n x 8
(or n x n x 4 if diagonal_neighbors == False)
Examples:
>>> from image_match.goldberg import ImageSignature
>>> gis = ImageSignature()
>>> gis.generate_signature('https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg')
array([ 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 2, 0,
0, 0, 0, 2, 2, 2, 2, 0, 0, 0, -2, 2, 2, 1, 2, 0, 0,
0, -2, 2, -1, -1, 2, 0, 0, 0, -2, -2, -2, -2, -1, 0, 0, 0,
2, -1, 2, 2, 2, 0, 0, 0, 1, -1, 2, 2, -1, 0, 0, 0, 1,
0, 2, -1, 0, 0, -2, -2, 0, -2, 0, 2, 2, -2, -2, -2, 2, 2,
2, 2, 2, -2, -2, -2, -2, -2, 1, 2, -2, -2, -1, 1, 2, 1, 2,
-1, 1, -2, 1, 2, -1, 2, -1, 0, 2, -2, 2, -2, -2, 1, -2, 1,
2, 1, -2, -2, -1, -2, 1, 1, -1, -2, -2, -2, 2, -2, 2, 2, 2,
1, 1, 0, 2, 0, 2, 2, 0, 0, -2, -2, 0, 1, 0, -1, 1, -2,
-2, -1, -1, 1, -1, 1, 1, -2, -2, -2, -1, -2, -1, 1, -1, 2, 1,
1, 2, 1, 2, 2, -1, -1, 0, 2, -1, 2, 2, -1, -1, -2, -1, -1,
-2, 1, -2, -2, -1, -2, -1, -2, -1, -2, -2, -1, -1, 1, -2, -2, 2,
-1, 1, 1, -2, -2, -2, 0, 1, 0, 1, -1, 0, 0, 1, 1, 0, 1,
0, 1, 1, -1, -1, 1, -1, 1, -1, 1, -1, -1, -1, -2, -1, -1, -1,
-2, -2, 1, -2, -2, 1, -2, -2, -2, -2, 1, 1, 2, 2, 1, -2, -2,
-1, 1, 2, 2, -1, 2, -2, -1, 1, 1, 1, -1, -2, -1, -2, -2, 0,
1, -1, -1, 1, -2, -2, 0, 1, 2, 1, 0, 2, 0, 2, 2, 0, 0,
-1, 1, 0, 1, 0, 1, 2, -1, -1, 1, -1, -1, -1, 2, 1, 1, 2,
2, 1, -2, 2, 2, 1, 2, 2, 2, 2, -1, 2, 2, 2, 2, 2, 2,
1, 2, 2, 2, 2, 1, 1, 2, -2, 2, 2, 2, 2, -1, 2, 2, -2,
2, 2, 2, 2, 0, 0, -2, -2, 1, 0, -1, 1, -1, -2, 0, -1, 0,
-1, 1, 0, 0, -1, 1, 0, 2, 0, 2, 2, -2, -2, -2, -2, -1, -1,
-1, 0, -1, -2, -2, 1, -1, -1, 1, 1, -1, -2, -2, 1, 1, 1, 1,
2, -2, -2, -2, -1, 1, 1, 1, 2, -2, -2, -2, -1, -1, 0, 1, 1,
-2, -2, 0, 1, -1, 1, 1, 1, -2, 1, 1, 1, 2, 2, 2, 2, -1,
-1, 0, -2, 0, 0, 1, 0, 0, -2, 1, 0, -1, 0, -1, -2, -2, 1,
1, 1, 1, -1, -1, -2, 0, -1, -1, -1, -1, -2, -2, -2, -1, -1, -1,
1, 1, -2, -2, 1, -2, -1, 0, -1, 0, -2, -1, 1, -2, -1, -1, 0,
0, -1, 0, 0, -1, -1, -2, 0, -1, 0, 0, -1, -1, -2, 0, 1, 1,
1, 0, 1, -2, -1, 0, -1, 0, -1, 0, 0, 0, 1, 1, 0, -1, 0,
2, -1, 2, 1, 2, 1, -2, 2, -1, -2, 2, 2, 2, 2, 2, 2, 2,
2, 2, 2, 2, -2, 2, 1, 2, 2, -1, 1, 1, -2, 1, -2, -2, -1,
-1, 0, 0, -1, 0, -2, -1, -1, 0, 0, -1, 0, -1, -1, -1, -1, 1,
0, 1, 1, 1, -1, 0, 1, -1, 0, 0, -1, 0, -1, 0, 0, 0, -2,
-2, 0, -2, 0, 0, 0, 1, 1, -2, 2, -2, 0, 0, 0, 2, -2, -1,
2, 2, 0, 0, 0, -2, -2, 2, -2, 1, 0, 0, 0, -2, 2, 2, -1,
2, 0, 0, 0, 1, 1, 1, -2, 1, 0, 0, 0, 1, 1, 1, -1, 1,
0, 0, 0, 1, 0, 1, -1, 1, 0, 0, 0, -1, 0, 0, -1, 0, 0,
0, 0], dtype=int8)
"""
# Step 1: Load image as array of grey-levels
im_array = self.preprocess_image(
path_or_image,
bytestream=bytestream,
handle_mpo=self.handle_mpo,
)
# Step 2a: Determine cropping boundaries
if self.crop_percentiles is not None:
image_limits = self.crop_image(im_array, lower_percentile=self.lower_percentile, upper_percentile=self.upper_percentile, fix_ratio=self.fix_ratio)
else:
image_limits = None
# Step 2b: Generate grid centers
x_coords, y_coords = self.compute_grid_points(im_array, n=self.n, window=image_limits)
# Step 3: Compute grey level mean of each P x P
# square centered at each grid point
avg_grey = self.compute_mean_level(im_array, x_coords, y_coords, P=self.P)
# Step 4a: Compute array of differences for each
# grid point vis-a-vis each neighbor
diff_mat = self.compute_differentials(avg_grey, diagonal_neighbors=self.diagonal_neighbors)
# Step 4b: Bin differences to only 2n+1 values
self.normalize_and_threshold(diff_mat, identical_tolerance=self.identical_tolerance, n_levels=self.n_levels)
# Step 5: Flatten array and return signature
return np.ravel(diff_mat).astype("int8")
[docs]
@staticmethod
def preprocess_image(
image_or_path: ImageInput,
bytestream: bool = False,
handle_mpo: bool = False,
) -> np.ndarray:
"""Loads an image and converts to greyscale.
Corresponds to 'step 1' in Goldberg's paper
Args:
image_or_path (string or numpy.ndarray): image path, or image array
bytestream (Optional[boolean]): will the image be passed as raw bytes?
That is, is the 'path_or_image' argument an in-memory image?
(default False)
handle_mpo (Optional[boolean]): try to compute a signature for steroscopic
images by extracting the first image of the set (default False)
Returns:
Array of floats corresponding to greyscale level at each pixel
Examples:
>>> gis.preprocess_image('https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg')
array([[ 0.26344431, 0.32423294, 0.30406745, ..., 0.35069725,
0.36499961, 0.36361569],
[ 0.29676627, 0.28640118, 0.34523255, ..., 0.3703051 ,
0.34931333, 0.31655686],
[ 0.35305216, 0.31858431, 0.36202 , ..., 0.40588196,
0.37284275, 0.30871373],
...,
[ 0.05932863, 0.05540706, 0.05540706, ..., 0.01954745,
0.01954745, 0.01562588],
[ 0.0632502 , 0.05540706, 0.05148549, ..., 0.01954745,
0.02346902, 0.01562588],
[ 0.06717176, 0.05540706, 0.05148549, ..., 0.02346902,
0.02739059, 0.01954745]])
"""
if bytestream:
if not isinstance(image_or_path, (bytes, bytearray, memoryview)):
raise TypeError("bytestream=True requires raw image bytes")
try:
img = Image.open(BytesIO(image_or_path))
except OSError:
# could be an svg, attempt to convert
if svg2png is None:
raise CorruptImageError()
try:
png = svg2png(bytestring=image_or_path)
img = Image.open(BytesIO(png)) if png else None
except Exception:
# cairosvg raises defusedxml exceptions, ParseError,
# ValueError, etc. -- any failure means undecodable input
img = None
if img is None:
raise CorruptImageError()
try:
# PIL decodes lazily; truncated data raises here, not at open()
img = img.convert("RGB")
except OSError:
raise CorruptImageError()
return rgb2gray(np.asarray(img, dtype=np.uint8))
if isinstance(image_or_path, (str, os.PathLike)):
return imread(image_or_path, as_gray=True)
if isinstance(image_or_path, bytes):
try:
img = Image.open(image_or_path)
arr = np.array(img.convert("RGB"))
except OSError:
# try again due to PIL weirdness
return imread(image_or_path, as_gray=True)
return rgb2gray(arr)
# runtime dispatch for untyped callers; statically already narrowed
if isinstance(image_or_path, np.ndarray): # pyright: ignore[reportUnnecessaryIsInstance]
try:
return rgb2gray(image_or_path)
except ValueError:
# image already converted to grayscale
return image_or_path
raise TypeError("Path or image required.")
[docs]
@staticmethod
def crop_image(image: np.ndarray, lower_percentile: int = 5, upper_percentile: int = 95, fix_ratio: bool = False) -> list[tuple[int, int]]:
"""Crops an image, removing featureless border regions.
Corresponds to the first part of 'step 2' in Goldberg's paper
Args:
image (numpy.ndarray): n x m array of floats -- the greyscale image. Typically, the
output of preprocess_image
lower_percentile (Optional[int]): crop image by percentage of difference (default 5)
upper_percentile (Optional[int]): as lower_percentile (default 95)
fix_ratio (Optional[boolean]): use the larger ratio for both directions. This is useful
for using the fast signature transforms on sparse but very similar images (e.g.
renderings from fixed directions). Use with care -- only use if you can guarantee the
incoming image is square (default False).
Returns:
A pair of tuples describing the 'window' of the image to use in analysis: [(top, bottom), (left, right)]
Examples:
>>> img = gis.preprocess_image('https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg')
>>> gis.crop_image(img)
[(36, 684), (24, 452)]
"""
# row-wise differences
rw = np.cumsum(np.sum(np.abs(np.diff(image, axis=1)), axis=1))
# column-wise differences
cw = np.cumsum(np.sum(np.abs(np.diff(image, axis=0)), axis=0))
# compute percentiles
upper_column_limit = np.searchsorted(cw, np.percentile(cw, upper_percentile), side="left")
lower_column_limit = np.searchsorted(cw, np.percentile(cw, lower_percentile), side="right")
upper_row_limit = np.searchsorted(rw, np.percentile(rw, upper_percentile), side="left")
lower_row_limit = np.searchsorted(rw, np.percentile(rw, lower_percentile), side="right")
# if image is nearly featureless, use default region
if lower_row_limit > upper_row_limit:
lower_row_limit = int(lower_percentile / 100.0 * image.shape[0])
upper_row_limit = int(upper_percentile / 100.0 * image.shape[0])
if lower_column_limit > upper_column_limit:
lower_column_limit = int(lower_percentile / 100.0 * image.shape[1])
upper_column_limit = int(upper_percentile / 100.0 * image.shape[1])
# searchsorted returns np.intp; normalize to plain ints
lower_row_limit = int(lower_row_limit)
upper_row_limit = int(upper_row_limit)
lower_column_limit = int(lower_column_limit)
upper_column_limit = int(upper_column_limit)
# if fix_ratio, return both limits as the larger range
if fix_ratio:
if (upper_row_limit - lower_row_limit) > (upper_column_limit - lower_column_limit):
return [(lower_row_limit, upper_row_limit), (lower_row_limit, upper_row_limit)]
else:
return [(lower_column_limit, upper_column_limit), (lower_column_limit, upper_column_limit)]
# otherwise, proceed as normal
return [(lower_row_limit, upper_row_limit), (lower_column_limit, upper_column_limit)]
[docs]
@staticmethod
def compute_grid_points(image: np.ndarray, n: int = 9, window: list[tuple[int, int]] | None = None) -> tuple[np.ndarray, np.ndarray]:
"""Computes grid points for image analysis.
Corresponds to the second part of 'step 2' in the paper
Args:
image (numpy.ndarray): n x m array of floats -- the greyscale image. Typically,
the output of preprocess_image
n (Optional[int]): number of gridpoints in each direction (default 9)
window (Optional[List[Tuple[int]]]): limiting coordinates [(t, b), (l, r)], typically the
output of (default None)
Returns:
tuple of arrays indicating the vertical and horizontal locations of the grid points
Examples:
>>> img = gis.preprocess_image('https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg')
>>> window = gis.crop_image(img)
>>> gis.compute_grid_points(img, window=window)
(array([100, 165, 230, 295, 360, 424, 489, 554, 619]),
array([ 66, 109, 152, 195, 238, 280, 323, 366, 409]))
"""
# if no limits are provided, use the entire image
if window is None:
window = [(0, image.shape[0]), (0, image.shape[1])]
x_coords = np.linspace(window[0][0], window[0][1], n + 2, dtype=int)[1:-1]
y_coords = np.linspace(window[1][0], window[1][1], n + 2, dtype=int)[1:-1]
return x_coords, y_coords # return pairs
[docs]
@staticmethod
def compute_mean_level(image: np.ndarray, x_coords: np.ndarray, y_coords: np.ndarray, P: float | None = None) -> np.ndarray:
"""Computes array of greyness means.
Corresponds to 'step 3'
Args:
image (numpy.ndarray): n x m array of floats -- the greyscale image. Typically,
the output of preprocess_image
x_coords (numpy.ndarray): array of row numbers
y_coords (numpy.ndarray): array of column numbers
P (Optional[int]): size of boxes in pixels (default None)
Returns:
an N x N array of average greyscale around the gridpoint, where N is the
number of grid points
Examples:
>>> img = gis.preprocess_image('https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg')
>>> window = gis.crop_image(img)
>>> grid = gis.compute_grid_points(img, window=window)
>>> gis.compute_mean_level(img, grid[0], grid[1])
array([[ 0.62746325, 0.62563642, 0.62348078, 0.50651686, 0.37438874,
0.0644063 , 0.55968952, 0.59356148, 0.60473832],
[ 0.35337797, 0.50272543, 0.27711346, 0.42384226, 0.39006181,
0.16773968, 0.10471924, 0.33647144, 0.62902124],
[ 0.20307514, 0.19021892, 0.12435402, 0.44990121, 0.38527996,
0.08339507, 0.05530059, 0.18469107, 0.21125228],
[ 0.25727387, 0.1669419 , 0.08964046, 0.1372754 , 0.48529236,
0.39894004, 0.10387907, 0.11282135, 0.30014612],
[ 0.23447867, 0.15702549, 0.25232943, 0.75172715, 0.79488688,
0.4943538 , 0.29645163, 0.10714578, 0.0629376 ],
[ 0.22167555, 0.04839472, 0.10125833, 0.1550749 , 0.14346914,
0.04713144, 0.10095568, 0.15349296, 0.04456733],
[ 0.09233709, 0.11210942, 0.05361996, 0.07066566, 0.04191625,
0.03548839, 0.03420656, 0.05025029, 0.03519956],
[ 0.19226873, 0.20647194, 0.62972106, 0.45514529, 0.05620413,
0.03383168, 0.03413588, 0.04741828, 0.02987698],
[ 0.05799523, 0.23310153, 0.43719717, 0.27666873, 0.25106573,
0.11094163, 0.10180622, 0.04633349, 0.02704855]])
"""
p = P if P is not None else max(2.0, int(0.5 + min(image.shape) / 20.0)) # per the paper
avg_grey = np.zeros((x_coords.shape[0], y_coords.shape[0]))
for i, x in enumerate(x_coords): # not the fastest implementation
lower_x_lim = int(max([x - p / 2, 0]))
upper_x_lim = int(min([lower_x_lim + p, image.shape[0]]))
for j, y in enumerate(y_coords):
lower_y_lim = int(max([y - p / 2, 0]))
upper_y_lim = int(min([lower_y_lim + p, image.shape[1]]))
avg_grey[i, j] = np.mean(image[lower_x_lim:upper_x_lim, lower_y_lim:upper_y_lim]) # no smoothing here as in the paper
return avg_grey
[docs]
@staticmethod
def compute_differentials(grey_level_matrix: np.ndarray, diagonal_neighbors: bool = True) -> np.ndarray:
"""Computes differences in greylevels for neighboring grid points.
First part of 'step 4' in the paper.
Returns n x n x 8 rank 3 array for an n x n grid (if diagonal_neighbors == True)
The n x nth coordinate corresponds to a grid point. The eight values are
the differences between neighboring grid points, in this order:
upper left
upper
upper right
left
right
lower left
lower
lower right
Args:
grey_level_matrix (numpy.ndarray): grid of values sampled from image
diagonal_neighbors (Optional[boolean]): whether or not to use diagonal
neighbors (default True)
Returns:
a n x n x 8 rank 3 numpy array for an n x n grid (if diagonal_neighbors == True)
Examples:
>>> img = gis.preprocess_image('https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg')
>>> window = gis.crop_image(img)
>>> grid = gis.compute_grid_points(img, window=window)
>>> grey_levels = gis.compute_mean_level(img, grid[0], grid[1])
>>> gis.compute_differentials(grey_levels)
array([[[ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00,
0.00000000e+00, 1.82683143e-03, -0.00000000e+00,
2.74085276e-01, 1.24737821e-01],
[ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00,
-1.82683143e-03, 2.15563930e-03, 2.72258444e-01,
1.22910990e-01, 3.48522956e-01],
[ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00,
-2.15563930e-03, 1.16963917e-01, 1.20755351e-01,
3.46367317e-01, 1.99638513e-01],
[ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00,
-1.16963917e-01, 1.32128118e-01, 2.29403399e-01,
8.26745956e-02, 1.16455050e-01],
...
"""
right_neighbors = -np.concatenate((np.diff(grey_level_matrix), np.zeros(grey_level_matrix.shape[0]).reshape((grey_level_matrix.shape[0], 1))), axis=1)
left_neighbors = -np.concatenate((right_neighbors[:, -1:], right_neighbors[:, :-1]), axis=1)
down_neighbors = -np.concatenate((np.diff(grey_level_matrix, axis=0), np.zeros(grey_level_matrix.shape[1]).reshape((1, grey_level_matrix.shape[1]))))
up_neighbors = -np.concatenate((down_neighbors[-1:], down_neighbors[:-1]))
if diagonal_neighbors:
# this implementation will only work for a square (m x m) grid
diagonals = np.arange(-grey_level_matrix.shape[0] + 1, grey_level_matrix.shape[0])
upper_left_neighbors = _diagonal_differences(grey_level_matrix, diagonals)
lower_right_neighbors = -np.pad(upper_left_neighbors[1:, 1:], (0, 1), mode="constant")
# flip for anti-diagonal differences
flipped_upper_right = _diagonal_differences(np.fliplr(grey_level_matrix), diagonals)
flipped_lower_left = -np.pad(flipped_upper_right[1:, 1:], (0, 1), mode="constant")
return np.dstack([
upper_left_neighbors,
up_neighbors,
np.fliplr(flipped_upper_right),
left_neighbors,
right_neighbors,
np.fliplr(flipped_lower_left),
down_neighbors,
lower_right_neighbors,
])
return np.dstack([up_neighbors, left_neighbors, right_neighbors, down_neighbors])
[docs]
@staticmethod
def normalize_and_threshold(difference_array: np.ndarray, identical_tolerance: float = 2 / 255.0, n_levels: int = 2) -> None:
"""Normalizes difference matrix in place.
'Step 4' of the paper. The flattened version of this array is the image signature.
Args:
difference_array (numpy.ndarray): n x n x l array, where l are the differences between
the grid point and its neighbors. Typically the output of compute_differentials
identical_tolerance (Optional[float]): maximum amount two gray values can differ and
still be considered equivalent (default 2/255)
n_levels (Optional[int]): bin differences into 2 n + 1 bins (e.g. n_levels=2 -> [-2, -1,
0, 1, 2])
Examples:
>>> img = gis.preprocess_image('https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg')
>>> window = gis.crop_image(img)
>>> grid = gis.compute_grid_points(img, window=window)
>>> grey_levels = gis.compute_mean_level(img, grid[0], grid[1])
>>> m = gis.compute_differentials(grey_levels)
>>> m
array([[[ 0., 0., 0., 0., 0., 0., 2., 2.],
[ 0., 0., 0., 0., 0., 2., 2., 2.],
[ 0., 0., 0., 0., 2., 2., 2., 2.],
[ 0., 0., 0., -2., 2., 2., 1., 2.],
[ 0., 0., 0., -2., 2., -1., -1., 2.],
[ 0., 0., 0., -2., -2., -2., -2., -1.],
[ 0., 0., 0., 2., -1., 2., 2., 2.],
[ 0., 0., 0., 1., -1., 2., 2., -1.],
[ 0., 0., 0., 1., 0., 2., -1., 0.]],
[[ 0., -2., -2., 0., -2., 0., 2., 2.],
[-2., -2., -2., 2., 2., 2., 2., 2.],
[-2., -2., -2., -2., -2., 1., 2., -2.],
[-2., -1., 1., 2., 1., 2., -1., 1.],
[-2., 1., 2., -1., 2., -1., 0., 2.],
...
"""
# set very close values as equivalent
mask = np.abs(difference_array) < identical_tolerance
difference_array[mask] = 0.0
# if image is essentially featureless, exit here
if np.all(mask):
return None
# bin so that size of bins on each side of zero are equivalent
positive_differences = difference_array[difference_array > 0.0]
negative_differences = difference_array[difference_array < 0.0]
positive_cutoffs = np.percentile(positive_differences, np.linspace(0, 100, n_levels + 1)) if positive_differences.size else None
negative_cutoffs = np.percentile(negative_differences, np.linspace(100, 0, n_levels + 1)) if negative_differences.size else None
if positive_cutoffs is not None:
for level, interval in enumerate([positive_cutoffs[i : i + 2] for i in range(positive_cutoffs.shape[0] - 1)]):
difference_array[(difference_array >= interval[0]) & (difference_array <= interval[1])] = level + 1
if negative_cutoffs is not None:
for level, interval in enumerate([negative_cutoffs[i : i + 2] for i in range(negative_cutoffs.shape[0] - 1)]):
difference_array[(difference_array <= interval[0]) & (difference_array >= interval[1])] = -(level + 1)
return None
[docs]
@staticmethod
def normalized_distance(_a: np.ndarray, _b: np.ndarray) -> float:
"""Compute normalized distance between two points.
Computes || b - a || / ( ||b|| + ||a||)
Args:
_a (numpy.ndarray): array of size m
_b (numpy.ndarray): array of size m
Returns:
normalized distance between signatures (float)
Examples:
>>> a = gis.generate_signature('https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg')
>>> b = gis.generate_signature('https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg')
>>> gis.normalized_distance(a, b)
0.22095170140933634
"""
b = _b.astype(int)
a = _a.astype(int)
norm_diff = np.linalg.norm(b - a)
norm1 = np.linalg.norm(b)
norm2 = np.linalg.norm(a)
with np.errstate(invalid="ignore", divide="ignore"):
return float(norm_diff / (norm1 + norm2))