import base64
import os
import time
from pathlib import Path
from datetime import datetime
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon, QPixmap
from PyQt5.QtWidgets import QGraphicsColorizeEffect, QGraphicsScene
from polo import (DEFAULT_IMAGE_PATH, IMAGE_CLASSIFICATIONS, MODEL,
make_default_logger, BLANK_IMAGE)
from polo.utils.io_utils import BarTender
from polo.marco.run_marco import run_model
logger = make_default_logger(__name__)
[docs]class Image(QtGui.QPixmap):
'''Image objects hold the data relating to one image in a particular
screening well of a paricular run. Images encode the actual image file as
a file path to the image if it available on the local machine, as
as a base64 encoded image held in memory or both.
:param path: Path to the actual image file, defaults to None
:type path: str or Path, optional
:param bites: Image encoded as base64, defaults to None
:type bites: str or bytes, optional
:param well_number: Well number of the image, defaults to None
:type well_number: int, optional
:param human_class: Human classification of this image, defaults to None
:type human_class: str, optional
:param machine_class: MARCO classification of this image, defaults to None
:type machine_class: str, optional
:param prediction_dict: Dictionary containing MARCO model confidence for
all image classifications, defaults to None
:type prediction_dict: dict, optional
:param plate_id: HWI given unique ID for plate this image belongs to
, defaults to None
:type plate_id: str, optional
:param date: Date this image was taken on, defaults to None
:type date: Datetime, optional
:param cocktail: Cocktail assigned to the well this image is of, defaults to None
:type cocktail: Cocktail, optional
:param spectrum: Keyword describing the imaging tech used to take the image
, defaults to None
:type spectrum: str, optional
:param previous_image: Image of the same well and sample but taken on a previous date
, defaults to None
:type previous_image: Image, optional
:param next_image: Image of the same well and sample but taken on a future
date, defaults to None
:type next_image: Image, optional
:param alt_image: Image of the same well and sample but taken with a
different imaging tech, defaults to None
:type alt_image: Image, optional
'''
def __init__(self, path=None, bites=None, well_number=None, human_class=None,
machine_class=None, prediction_dict={},
plate_id=None, date=None, cocktail=None, spectrum=None,
previous_image=None, next_image=None, alt_image=None,
favorite=False, parent=None, **kwargs):
super(Image, self).__init__(parent)
self.path = str(path)
self.bites = bites
self.human_class = human_class
self.machine_class = machine_class
self.well_number = well_number
self.prediction_dict = prediction_dict
self.plate_id = plate_id
self.date = date
self.cocktail = cocktail
self.spectrum = spectrum
self.previous_image = previous_image
self.next_image = next_image
self.alt_image = alt_image
self.favorite = favorite
[docs] @staticmethod
def clean_base64_string(string):
'''Image instances may contain byte strings that store their actual
crystallization image encoded as base64. Previously, these byte strings
were written directly into the json file as strings causing the `b`
byte string identifier to be written along with the actual base64 data.
This method removes those artifacts if they are present and returns a
clean byte string with only the actual base64 data.
:param string: A string to interrogate for base64 compliance
:type string: str
:return: byte string with non-data artifacts removed
:rtype: bytes
'''
if string:
if isinstance(string, bytes):
string = str(string, 'utf-8')
if string[0] == 'b': # bytes string written directly to string
string = string[1:]
if string[-1] == "'":
string = string[:-1]
if string:
return bytes(string, 'utf-8')
[docs] @classmethod
def to_graphics_scene(cls, image):
'''Convert an Image object to a `QGraphicsScene` with
the Image added as a pixmap to the `QGraphicsScene`.
:param image: Image instance
:type image: Image
:return: QGraphicsScene
:rtype: QGraphicsScene
'''
scene = QtWidgets.QGraphicsScene()
if image.isNull():
image.setPixmap()
scene.addPixmap(image)
return scene
[docs] @classmethod
def no_image(cls):
'''Return an :class:`~polo.crystallography.image.Image`
instance using the image data referenced by the
:const:`polo.DEFAULT_IMAGE_PATH` constant.
The default image is used to fill in for missing
data and when filters cannot find any matching results.
:return: Default :class:`~polo.crystallography.image.Image`
:rtype: Image
'''
# return default no images found image instance
return cls(path=BLANK_IMAGE)
@property
def date(self):
'''The date associated with this
:class:`~polo.crystallography.image.Image`.
Presumably should be the date the image was taken.
:return: Datetime object representation of
the :class:`~polo.crystallography.image.Image`'s
imaging date
:rtype: datetime
'''
return self._date
@date.setter
def date(self, date):
if isinstance(date, str):
d = BarTender.datetime_converter(date)
self._date = d
else:
self._date = date
@property
def path(self):
'''Filepath for the image. Note that if this path is loaded
from an xtal file, this path may not exists because the xtal
file may have been created on a different machine.
:return: Path to image file
:rtype: str
'''
return self._path
@path.setter
def path(self, new_path):
if new_path:
if isinstance(new_path, Path):
self._path = str(new_path)
self._path = new_path
else:
self._path = None
@property
def bites(self):
return self._bites
@bites.setter
def bites(self, new_bites):
if isinstance(new_bites, bytes):
self._bites = new_bites
elif isinstance(new_bites, str):
self._bites = Image.clean_base64_string(new_bites)
else:
self._bites = None
@property
def prediction_dict(self):
return self._prediction_dict
@prediction_dict.setter
def prediction_dict(self, new_dict):
try:
default = dict(zip(IMAGE_CLASSIFICATIONS, [0]*len(IMAGE_CLASSIFICATIONS)))
valid_dict = False
self._prediction_dict = new_dict
if all([im_cls in new_dict for im_cls in IMAGE_CLASSIFICATIONS]):
valid_dict = True
if valid_dict:
self._prediction_dict = new_dict
else:
self._prediction_dict = default
except Exception as e:
logger.critical('Caught {} setting prediction_dict value'.format(e))
self._prediction_dict = {} # try and save face
# had issues finding errors this method threw because setter
# decorator kind of hides them.
# @property
# def machine_class(self):
# '''MARCO classification of the :class:`~polo.crystallography.image.Image`.
# :return: Current MARCO classification of this image
# :rtype: str
# '''
# return self._machine_class
# @machine_class.setter
# def machine_class(self, new_class):
# '''Setter method for
# :attr:`~polo.crystallography.image.Image.machine_class` attribute.
# If this image has alt images linked to it and has its
# :attr:`~polo.crystallography.image.Image.spectrum` attribute set as
# 'Visible' linked alt images will share this :class:`~polo.crystallography.image.Image`'s
# :attr:`~polo.crystallography.image.Image.machine_class` attribute value.
# This is because the MARCO model has only been trained on visible light
# images and therefore is not cabable of reliably classifying images
# taken in differenet spectrums. Since linked alt images
# should in theory be images of the exact same
# well in the exact same plate the visible spectrum image can share
# its MARCO classification with it's linked alt images.
# :param new_class: New MARCO classification for the
# :class:`~polo.crystallography.image.Image`
# :type new_class: str
# '''
# if new_class in IMAGE_CLASSIFICATIONS:
# self._machine_class = new_class
# if hasattr(self, 'alt_image') and self.alt_image and self.spectrum == 'Visible':
# # alt images inherit their linked classifications
# alt_image = self.alt_image
# while alt_image.path and alt_image.path != self.path:
# alt_image._machine_class = new_class
# alt_image = alt_image.alt_image
# else:
# self._machine_class = None
# @property
# def human_class(self):
# '''Return the :attr:`~polo.crystallography.image.Image.human_class`
# attribute which specifies the current human classification of the
# :class:`~polo.crystallography.image.Image`.
# :return: Current human classification of the
# :class:`~polo.crystallography.image.Image`
# :rtype: str
# '''
# return self._human_class
# @human_class.setter
# def human_class(self, new_class):
# if new_class in IMAGE_CLASSIFICATIONS:
# self._human_class = new_class
# else:
# self._human_class = None
@property
def formated_date(self):
'''Get the image's :attr:`~polo.crystallography.image.Image.marco_date`
attribute formated in the month/date/year format. If the
:class:`~polo.crystallography.image.Image`
has no :attr:`~polo.crystallography.image.Image.date` returns
an empty string.
:return: Date
:rtype: str
'''
if isinstance(self.date, datetime):
return datetime.strftime(self.date, '%m/%d/%Y')
else:
return ''
@property
def is_placeholder(self):
if(str(self.path) == str(BLANK_IMAGE)
or str(self.path) == str(DEFAULT_IMAGE_PATH)):
return True
else:
return False
@property
def earliest_crystallization_date(self):
try:
dates = self.get_linked_images_by_date()
dates = sorted(dates, key=lambda i: i.date)
for i, each_date in enumerate(dates):
if dates[i].human_class == IMAGE_CLASSIFICATIONS[0]:
return dates[i].date
except Exception as e:
return None
[docs] def setPixmap(self, scaling=None):
'''Loads the :class:`~polo.crystallography.image.Image`'s
pixmap into memory which then allows for displaying
the :class:`~polo.crystallography.image.Image` to the user.
:class:`~polo.crystallography.image.Image`s
that are displayed before loading will not appear.
It is recommended to only load the
:class:`~polo.crystallography.image.Image` pixmap when
the :class:`~polo.crystallography.image.Image` actually
needs to be shown to the user as it is expensive
to hold in memory.
:param scaling: Scaler for the pixmap; between 0 and 1, defaults to None
:type scaling: float, optional
'''
if os.path.exists(self.path):
self.load(self.path)
elif isinstance(self.bites, bytes):
self.loadFromData(base64.b64decode(self.bites))
if isinstance(scaling, float):
self.scaled(self.width*scaling, self.height *
scaling, Qt.KeepAspectRatio)
[docs] def delete_pixmap_data(self):
'''Replaces the :class:`~polo.crystallography.image.Image`'s
pixmap data with a null pixmap which
effectively deletes the existing pixmap data. Used to free up
memory after a pixmap is no longer needed.
'''
self.swap(QPixmap()) # swap with null pixel map
[docs] def delete_all_pixmap_data(self):
'''Deletes the pixmap data for the
:class:`~polo.crystallography.image.Image` instance this method is
called on and for any other
:class:`~polo.crystallography.image.Image`s
that this :class:`~polo.crystallography.image.Image` is linked to.
This includes images referenced by the
:attr:`~polo.crystallography.image.Image.alt_image`
, :attr:`~polo.crystallography.image.Image.next_image` and
:attr:`~polo.crystallography.image.Image.previous_image`
attributes.
'''
self.delete_pixmap_data()
for i in self.get_linked_images_by_date():
i.delete_pixmap_data()
for i in self.get_linked_images_by_spectrum():
i.delete_pixmap_data()
[docs] def height(self):
'''Get the height of the
:class:`~polo.crystallography.image.Image`'s pixmap.
The pixmap must be set for this function to
return an actual size.
:return: Height of the :class:`~polo.crystallography.image.Image`'s pixmap
:rtype: int
'''
return self.size().height()
[docs] def width(self):
'''Get the height of the
:class:`~polo.crystallography.image.Image`'s pixmap.
The pixmap must be set for this function to return
an actual size.
:return: Width of the
:class:`~polo.crystallography.image.Image`'s
pixmap
:rtype: int
'''
return self.size().width()
def __str__(self):
image_string = 'Well Num: {}\n'.format(str(self.well_number))
image_string += 'MARCO Class: {}\nHuman Class: {}\n'.format(
str(self.machine_class), str(self.human_class))
if self.machine_class and self.prediction_dict and self.machine_class in self.prediction_dict:
image_string += 'MARCO Confidence: {} %\n'.format(
round(float(self.prediction_dict[self.machine_class]) * 100, 1)
)
image_string += 'Date: {}\n'.format(self.formated_date)
image_string += 'Spectrum: {}\n'.format(self.spectrum)
return image_string
[docs] def encode_base64(self):
if not self.bites and os.path.exists(self.path):
with open(self.path, 'rb') as image_file:
self.bites = base64.b64encode(image_file.read())
[docs] def encode_bytes(self):
'''If the :attr:`~polo.crystallography.image.Image.path`
attribute exists and is an image file then encodes
that file as a base64 string and returns the encoded
image data.
:return: base64 encoded image
:rtype: str
'''
if self.bites:
return self.bites
elif os.path.exists(self.path):
with open(self.path, 'rb') as image:
return base64.b64encode(image.read())
[docs] def get_linked_images_by_date(self):
'''Get all :class:`~polo.crystallography.image.Image`s
that are linked to this :class:`~polo.crystallography.image.Image`
instance by date. Image linking by date is accomplished
by creating a bi-directional linked list between
:class:`~polo.crystallography.image.Image` instances,
where each :class:`~polo.crystallography.image.Image` acts as a node
and the :attr:`~polo.crystallography.image.Image.next_image` and
:attr:`~polo.crystallography.image.Image.previous_images`
act as the forwards and backwards pointers respectively.
:return: All :class:`~polo.crystallography.image.Image`s
connected to this :class:`~polo.crystallography.image.Image`
by date
:rtype: list
'''
try:
linked_images = [self]
if self.next_image:
start_image = self.next_image
while isinstance(start_image, Image) and start_image.path != self.path:
linked_images.append(start_image)
start_image = start_image.next_image
if self.previous_image:
start_image = self.previous_image
while isinstance(start_image, Image) and self.previous_image.path != self.path:
linked_images.append(start_image)
start_image = start_image.previous_image
return sorted(linked_images, key=lambda i: i.date)
except Exception as e:
logger.error('Caught {} at {}'.format(e, self.get_linked_images_by_date))
return []
[docs] def get_linked_images_by_spectrum(self):
'''Get all :class:`~polo.crystallography.image.Image`s
that are linked to this :class:`~polo.crystallography.image.Image` instance by
spectrum. Linking images by spectrum is accomplished by
creating a mono-directional circular linked list where
:class:`~polo.crystallography.image.Image` instances serve as nodes and their
:attr:`~polo.crystallography.image.Image.alt_image` attribute
acts as the pointer to the next node.
:return: List of all `Images` linked to this
:class:`~polo.crystallography.image.Image` by spectrum
:rtype: list
'''
linked_images, paths = [self], set([])
if self.alt_image:
start_image = self.alt_image
while (isinstance(start_image, Image)
and start_image.path != self.path
and start_image.path not in paths):
linked_images.append(start_image)
paths.add(start_image.path)
start_image = start_image.alt_image
return sorted(linked_images, key=lambda i: len(i.spectrum))
else:
return linked_images
[docs] def classify_image(self):
'''Classify the :class:`~polo.crystallography.image.Image`
using the MARCO CNN model. Sets the
:attr:`~polo.crystallography.image.Image.machine class` and
:attr:`~polo.crystallography.image.Image.prediction_dict`
attributes based on the model results.
'''
try:
self.machine_class, self.prediction_dict = run_model(
MODEL, self.path)
except AttributeError as e:
logger.error('Caught {} at classify_image method'.format(e))
return e
[docs] def standard_filter(self, image_types, human, marco, favorite):
'''Method that determines if this
:class:`~polo.crystallography.image.Image` should be
included in a set of filtered :class:`~polo.crystallography.image.Image`s
based on given image classifications and a classifier: human,
marco or both. Returns True if the
:class:`~polo.crystallography.image.Image` meets the
filtering requirements specified by the method's arguments,
otherwise returns False.
:param image_types: Collection of image classifications.
The :class:`~polo.crystallography.image.Image`'s
classification must in included in this collection
for the method to return True.
:type image_types: list or set
:param human: If True, use the
:class:`~polo.crystallography.image.Image`'s
human classification as the
overall image classification.
:type human: bool
:param marco: If True, use the
:class:`~polo.crystallography.image.Image`'s
MARCO classification as the
overall image classification.
:type marco: bool
:return: True if the
:class:`~polo.crystallography.image.Image`
meets the filter requirements, False otherwise
:rtype: bool
'''
if favorite == self.favorite:
if image_types: # have specificed some image types
if self.human_class and human and self.human_class in image_types:
return True
elif self.machine_class and marco and self.machine_class in image_types:
return True
else:
return False
else:
if human or marco: # set at least one classifier filter
if (human and self.human_class) or (marco and self.machine_class):
return True
else:
return False
else:
return True # set no filters so return True
else:
return False
[docs] def write_from_bites(self, path):
'''Write the `Image` instance to a file using the base64 encoded data
stored in the :attr:`~polo.crystallography.image.Image.bites` attribute.
'''
if bites:
with open(path, 'wb') as handle:
handle.write(base64.decodebytes(self.bites))
return path
else:
raise Exception('Image has not been base64 encoded')