# -*- coding: utf-8 -*-
"""
analyzer.py
This module contains the Analyzer class, which is responsible for
handling and analyzing images in the context of the ForensicFit
application. It uses computer vision techniques for image analysis
and provides utilities for plotting the results and converting
images to and from byte buffers.
The module includes the following classes:
- Analyzer: An abstract base class that defines the necessary
interface for image analysis in the ForensicFit application.
Author: Pedram Tavadze
Email: petavazohi@gmail.com
"""
import io
from abc import ABCMeta, abstractmethod
from pathlib import Path
from typing import List, Union
import cv2
import matplotlib as mpl
import numpy as np
from matplotlib import pylab as plt
from matplotlib.axes import Axes
from scipy.stats import norm
from ..utils import copy_doc, image_tools, plotter
from .metadata import Metadata
IMAGE_EXTENSIONS = image_tools.IMAGE_EXTENSIONS
[docs]class Analyzer:
"""
Abstract base class that represents an analyzer in the system.
The Analyzer class is designed to be subclassed by concrete analyzer classes.
Each subclass should implement the methods that make sense for that specific
type of analyzer.
This class uses the Abstract Base Classes (ABC) module which enables the
creation of a blueprint for other classes. This means you can't create an
instance of this class, it is intended to be subclassed. All methods marked
with @abstractmethod must be implemented in any concrete (i.e.,
non-abstract) subclass.
Attributes
----------
image : np.ndarray
The image to be analyzed. This attribute is expected to be a numpy
array representing the image, but it's initially set to None.
values : dict
A dictionary that contains the results of the analysis. The keys are
strings describing what each value represents.
metadata : Metadata
An instance of the Metadata class, containing metadata related to the
analysis.
Notes
-----
This class is part of a module called "analyzer.py". It serves as the
parent class for all future analyzers in the system.
"""
__metaclass__ = ABCMeta
[docs] def __init__(self, **kwargs):
self.image = None
self.values = {}
self.metadata = Metadata({'mode': 'analysis',
'label': None,
'material': None})
self.metadata.update(kwargs)
# @copy_doc(image_tools.exposure_control)
[docs] def exposure_control(self, mode:str='equalize_hist', **kwargs):
self.original_image = self.image
self.image = image_tools.exposure_control(self.image, mode, **kwargs)
if self.metadata['remove_background']:
self.image = image_tools.remove_background(self.image,
self.largest_contour)
self.metadata['exposure_control'] = mode
if len(kwargs) != 0:
for key in kwargs:
self.metadata[key] = kwargs[key]
return
# @copy_doc(image_tools.apply_filter)
[docs] def apply_filter(self, mode:str, **kwargs):
self.original_image = self.image
self.image = image_tools.apply_filter(self['gray_scale'], mode, **kwargs)
self.metadata['filter'] = mode
if len(kwargs) != 0:
for key in kwargs:
self.metadata[key] = kwargs[key]
return
[docs] def resize(self, size: tuple = None, dpi: tuple = None):
"""
Resize the image associated with this analyzer.
The method allows to resize the image either by providing the desired
output size, or by providing the dots per inch (dpi). If both parameters
are None, the image will not be modified.
Parameters
----------
size : tuple, optional
Desired output size in pixels as (height, width). If provided, this
value will be used to resize the image. Default is None.
dpi : tuple, optional
Desired dots per inch (dpi) as (horizontal, vertical). If provided and
'dpi' key exists in the metadata, this value will be used to compute
the new size and then resize the image. Default is None.
Returns
-------
None
Raises
------
ValueError
If both 'size' and 'dpi' are None.
"""
if dpi is None and size is not None:
self.image = image_tools.resize(self.image, size)
self.values['image'] = self.image
self.metadata['resize'] = size
self.metadata['resolution'] = self.image.shape
elif dpi is not None and 'dpi' in self.metadata:
dpi = np.array(dpi, dtype=np.int_)
dpi_old = np.array(self.metadata.dpi, dtype=np.int_)
ratio = dpi/dpi_old
size = np.flip((np.array(self.shape)[:2]*ratio).round().astype(int))
self.image = image_tools.resize(self.image, size)
self.values['image'] = self.image
self.metadata['resize'] = size
self.metadata['resolution'] = self.image.shape
self.metadata['resolution'] = self.image.shape
self.metadata['dpi'] = dpi
else:
raise ValueError('Please provide either size or dpi')
[docs] def plot_boundary(self,
savefig: Union[str, Path] = None,
color: str='red',
ax: Axes = None,
show: bool=False):
"""
Plots the detected boundary of the image.
Parameters
----------
savefig : Union[str, Path], optional
Path to save the plot. If provided, the plot will be saved at the
specified location. If None, the plot will not be saved.
Default is None.
color : str, optional
Color of the boundary line in the plot. Default is 'red'.
ax : matplotlib.axes.Axes, optional
An instance of Axes in which to draw the plot. If None, a new
Axes instance will be created. Default is None.
show: bool, optional
Controls whether to show the image using matplotlib.pyplot.show()
after it is drawn. Default is False.
Returns
-------
ax : matplotlib.axes.Axes
The Axes instance in which the plot was drawn.
"""
if ax is None:
plt.figure(figsize = (16, 9))
ax = plt.subplot(111)
ax.plot(self.boundary[:, 0], self.boundary[:, 1], c = color)
if savefig is not None:
plt.savefig(savefig)
elif show:
plt.show()
return ax
[docs] def plot(self,
which: str,
cmap: str='gray',
zoom: int=4,
savefig: Union[str, Path] = None,
ax: Union[Axes, List[Axes]] = None,
show: bool = False,
mode: str = None,
**kwargs):
"""
Plots different kinds of data based on the given parameters.
Parameters
----------
which : str
Determines the kind of plot to be created. Possible values include
"coordinate_based", "boundary", "bin_based+coordinate_based",
"coordinate_based+bin_based", "bin_based",
"bin_based+max_contrast", "max_contrast+bin_based" and others.
cmap : str, optional
The Colormap instance or registered colormap name. Default is 'gray'.
zoom : int, optional
The zoom factor for the plot. Default is 4.
savefig : str, optional
Path and name to save the image. If None, the plot will not be saved.
Default is None.
ax : matplotlib.axes.Axes or List[matplotlib.axes.Axes], optional
An instance of Axes or list of Axes in which to draw the plot. If None,
a new Axes instance will be created. Default is None.
show : bool, optional
If True, displays the image. Default is False.
mode : str, optional
Determines the mode of operation, which affects how the plot is
generated. The effect depends on the value of `which`.
**kwargs
Arbitrary keyword arguments.
Returns
-------
ax : matplotlib.axes.Axes or List[matplotlib.axes.Axes]
The Axes instance(s) in which the plot was drawn.
"""
if which == "coordinate_based":
if ax is None:
figsize = plotter.get_figure_size(self.metadata['dpi'],
self.shape[:2],
zoom)
fig = plt.figure(figsize=figsize)
ax = fig.add_subplot(111)
coordinates = self['coordinate_based']['coordinates']
stds = self['coordinate_based']['stds']
slopes = self['coordinate_based']['slopes']
ax = plotter.plot_coordinate_based(coordinates,
slopes,
stds,
mode,
ax, **kwargs)
ax.set_xlim(0, self['image'].shape[1])
# ax.set_xlim(0, self.image.shape[1])
# ax.set_ylim(0, self.image.shape[0])
ax.invert_yaxis()
elif which == 'boundary':
ax = self.plot('image', cmap=cmap, ax=ax)
ax = self.plot_boundary(ax=ax)
ax_ = ax
elif which in ['bin_based+coordinate_based',
'coordinate_based+bin_based']:
if mode == 'individual_bins':
dynamic_positions = np.array(self.metadata[
'analysis']['bin_based']['dynamic_positions'])
xmin = min(dynamic_positions[:, 0, 0])
xmax = max(dynamic_positions[:, 0, 1])
n_bins = self.metadata['analysis']['bin_based']['n_bins']
if ax is None:
figure = plt.figure(figsize=(5, 2*n_bins))
ax = figure.subplots(
n_bins, 1,
sharex=True,
gridspec_kw={'hspace':2e-2})
elif isinstance(ax, list):
assert len(ax) >= n_bins, 'Number of Axes provided ' \
"smaller than the number of bins"
if n_bins == 1: ax=[ax]
for i, i_bin in enumerate(self[which]):
coordinates = i_bin['coordinates']
stds = i_bin['stds']
slopes = i_bin['slopes']
ax[i] = plotter.plot_coordinate_based(coordinates,
slopes,
stds,
mode,
ax[i], **kwargs)
dy = coordinates[1, 1] - coordinates[0, 1]
y_min, y_max = min(coordinates[:, 1]), max(coordinates[:, 1])
# ax[i].xaxis.set_visible(False)
# ax[i].yaxis.set_visible(False)
# ax[i].set_ylim(y_min-dy, y_max+dy)
y1 = dynamic_positions[i][1][0]
y2 = dynamic_positions[i][1][1]
x1 = dynamic_positions[i][0][0]
x2 = dynamic_positions[i][0][1]
ax[i].set_xlim(0, self.xmax)
ax[i].set_ylim(y2, y1)
if not ax[i].yaxis_inverted():
ax[i].invert_yaxis()
ax_ = ax[-1]
# ax_.set_xlim(xmin, xmax)
# ax.set_ylim(xmin, xmax)
else:
if ax is None:
plt.figure(figsize=(16, 9))
ax = plt.subplot(111)
for i, i_bin in enumerate(self[which]):
coordinates = i_bin['coordinates']
stds = i_bin['stds']
slopes = i_bin['slopes']
ax = plotter.plot_coordinate_based(coordinates,
slopes,
stds,
mode,
ax, **kwargs)
ax.set_ylim(0, self.image.shape[0])
ax.set_xlim(0, self.image.shape[1])
ax.invert_yaxis()
elif which in [
'bin_based',
'bin_based+max_contrast',
'max_contrast+bin_based']:
if mode == 'individual_bins':
dynamic_positions = self.metadata[
'analysis']['bin_based']['dynamic_positions']
n_bins = self.metadata['analysis']['bin_based']['n_bins']
if ax is None:
figure = plt.figure(figsize=(10, 20))
ax = figure.subplots(
n_bins, 1,
gridspec_kw={'hspace':2e-2})
elif isinstance(ax, list):
assert len(ax) >= n_bins, 'Number of Axes provided ' \
"smaller than the number of bins"
if n_bins == 1: ax=[ax]
bins = self[which]
for i, seg in enumerate(bins):
y1 = dynamic_positions[i][1][0]
y2 = dynamic_positions[i][1][1]
x1 = dynamic_positions[i][0][0]
x2 = dynamic_positions[i][0][1]
# ax[i].set_facecolor('black')
ax[i].imshow(seg, cmap=cmap, extent=(x1, x2, y1, y2))
# ax[i].set_xlim(x1, x2)
# ax[i].set_ylim(y1, y2)
ax[i].xaxis.set_visible(False)
ax[i].yaxis.set_visible(False)
# ax[i].imshow(seg, cmap=cmap)
ax_ = ax[-1]
else:
if ax is None:
plt.figure(figsize=(16, 9))
ax = plt.subplot(111)
dynamic_positions = self.metadata[
'analysis']['bin_based']['dynamic_positions']
colors = [
'red', 'blue', 'green', 'cyan', 'magenta'
]*len(dynamic_positions)
styles = [
'solid', 'dashed', 'dotted', 'dashdot'
]*len(dynamic_positions)
xs = []
for i, seg in enumerate(dynamic_positions):
y1 = seg[1][0]
y2 = seg[1][1]
x1 = seg[0][0]
x2 = seg[0][1]
xs.append(x1)
xs.append(x2)
ax.plot([x1, x1], [y1, y2], color=colors[i],
linestyle=styles[i], linewidth=1)
ax.plot([x2, x2], [y1, y2], color=colors[i],
linestyle=styles[i], linewidth=1)
ax.plot([x1, x2], [y1, y1], color=colors[i],
linestyle=styles[i], linewidth=1)
ax.plot([x1, x2], [y2, y2], color=colors[i],
linestyle=styles[i], linewidth=1)
if 'max_contrast' in which:
ax = self.plot('edge_bw', ax=ax, cmap=cmap)
ax_ = ax
else:
ax = self.plot('image', ax=ax, cmap=cmap)
ax_ = ax
else:
if ax is None:
figsize = plotter.get_figure_size(self.metadata['dpi'],
self.shape[:2],
zoom)
plt.figure(figsize = figsize)
ax = plt.subplot(111)
ax.imshow(self[which], cmap=cmap)
ax_ = ax
ax.set_xlim(0, self[which].shape[1])
if 'coordinate_based' not in which:
ax_.xaxis.set_visible(False)
ax_.yaxis.set_visible(False)
if savefig is not None:
plt.savefig(savefig)
return ax
if show:
plt.show()
else:
return ax
[docs] @abstractmethod
def load_dict(self):
"""
Abstract method for loading a dictionary.
This method should be implemented by any non-abstract subclass of
Analyzer. The implementation should handle the loading of some kind
of dictionary data specific to that subclass.
Returns
-------
Typically, this method would return the loaded dictionary, but the
exact return type and value will depend on the specific implementation
in the subclass.
"""
pass
[docs] @abstractmethod
def from_dict(self):
"""
Abstract method for setting the state of an object from a dictionary.
This method should be implemented by any non-abstract subclass of
Analyzer. The implementation should set the state of an object based
on data provided in a dictionary.
Parameters
----------
This will vary depending on the subclass implementation, but typically
this method would accept a single argument: the dictionary containing
the data to use when setting the object's state.
Returns
-------
Typically, this method would not return a value, but this will depend
on the specific implementation in the subclass.
"""
pass
[docs] @classmethod
def from_buffer(cls,
buffer: bytes,
metadata: dict,
ext: str='.png'):
"""
Receives an io byte buffer with the corresponding metadata and
creates an instance of the class. This class method is helpful in
situations where you have raw image data along with associated metadata
and need to create an Analyzer object.
Parameters
----------
buffer : bytes
A buffer containing raw image data, typically in the form of bytes.
metadata : dict
A dictionary containing metadata related to the image. The specific
contents will depend on your application, but might include things
like the image's origin, resolution, or creation date.
ext : str, optional
The file extension of the image being loaded. Used to determine
the decoding method. Default is '.png'. If the 'ext' key is present
in the metadata dict, it will override this parameter.
Returns
-------
An instance of the Analyzer class, initialized with the image and
metadata provided.
"""
if 'ext' in metadata:
ext = metadata['ext']
if ext in IMAGE_EXTENSIONS:
image = cv2.imdecode(np.frombuffer(buffer, np.uint8), -1)
return cls.from_dict(image, metadata)
[docs] def to_buffer(self, ext: str = '.png') -> bytes:
"""
Converts the current instance of the Analyzer class to a byte buffer,
which can be useful for serialization or for writing to a file. This
method supports various image formats determined by the extension provided.
Parameters
----------
ext : str, optional
The file extension for the output buffer. This will determine the
format of the output image. Default is '.png'. This method supports
any image format that is recognized by the OpenCV library.
Returns
-------
bytes
A byte string representing the image data in the format specified
by 'ext'. This can be directly written to a file or transmitted
over a network, among other things.
Raises
------
ValueError
If the provided extension is not supported, a ValueError will be raised.
"""
if ext in IMAGE_EXTENSIONS:
_, buffer = cv2.imencode(ext, self.image)
output = io.BytesIO(buffer)
else:
raise ValueError("Extension not supported")
return output.getvalue()
@property
def shape(self) -> tuple:
"""
A property that provides the shape of the image contained in the Analyzer instance.
Returns
-------
tuple
A tuple representing the shape of the image. For grayscale images,
this will be a 2-tuple (height, width). For color images, this
will be a 3-tuple (height, width, channels), where 'channels'
is typically 3 for an RGB image or 4 for an RGBA image.
"""
return self.image.shape
def __contains__(self, x):
return x in self.values
def __getitem__(self, x):
return self.values.__getitem__(x)
def __iter__(self):
return self.values.__iter__()
def __len__(self):
return self.values.__len__()
def __repr__(self) -> str:
"""
A string representation method for the Analyzer class.
This method plots the boundary of the image and prints the metadata
of the image.
Returns
-------
str
A string representing the metadata of the image.
"""
self.plot(which='boundary', show=True)
ret = self.metadata.__str__()
return ret