from __future__ import annotations
import math
import warnings
from enum import Enum
from shapely.geometry import Polygon
import numpy as np
from functools import lru_cache
from SCTrack.utils import convert_dtype
from SCTrack import config
[docs]def NoneTypeFilter(func):
def _filter(self, *args, **kwargs):
ret = func(self, *args, **kwargs)
cells = []
for i in ret:
if i.mcy.size != 0:
cells.append(i)
return cells
return _filter
[docs]def warningFilter(func):
warnings.filterwarnings("ignore")
def _warn_filter(*args, **kwargs):
return func(*args, **kwargs)
return _warn_filter
[docs]class MatchStatus(Enum):
"""
Matching status enumeration object,
including matched, unmatched, and missing matches,
is the status value of TrackingTree.
"""
Matched = 0
Unmatched = 1
LossMatch = 2
[docs]class TreeStatus(object):
"""
Record the state of the tracked cells, including cell cell_type status, division status, and matching status
Phase: whether Mitosis has entered, and which frame has entered Mitosis
Division: whether there is Mitosis
Matching situation: Is there any loss of matching in this cell
"""
# TreeStatus all Status properties
__status_types = ['enter_mitosis', 'enter_mitosis_frame', 'division_event_happen',
'division_count', 'exit_mitosis', 'exit_mitosis_frame']
_instances = {}
def __new__(cls, *args, **kwargs):
key = str(args) + str(kwargs)
if key not in cls._instances:
cls._instances[key] = super().__new__(cls)
cls._instances[key].__tracking_tree = None
cls._instances[key].__init_flag = False
cls._instances[key].enter_mitosis_threshold = config.ENTER_DIVISION_THRESHOLD
# The cell division window stage, with an initial value of 20, will exit division matching when no cell
# division is matched or cell division is completed during this window period. After entering the split
# window period, this value will decrease every frame forward.
cls._instances[key].division_windows_len = 20
# Starting from the completion of splitting and exiting mitosis, counting, no further entry into mitosis
# is allowed within 10 frames, that is, when __exit_mitosis_time < 10, self.__ enter_ mitosis cannot be true
cls._instances[key].__exit_mitosis_time = cls._instances[key].enter_mitosis_threshold
return cls._instances[key]
def __init__(self, tree: 'TrackingTree | None'):
if not self.__init_flag:
self.__tracking_tree = tree
self.__enter_mitosis: bool = False
self.__enter_mitosis_frame: int | None = None
self.__division_event_happen: bool = False # True indicates that the cell has at least one M itosis
self.__division_count: int = 0
self.__exit_mitosis: bool = False
self.__exit_mitosis_frame: int | None = None
self.__match_status = MatchStatus.Unmatched
# This value records the number of predicted M cell_type. If > 3, it is considered to have entered M period,
# and at this point, enter needs to be called externally_ Mitosis()
self.__predict_M_count = 0
self.__init_flag = True
@property
def status(self):
return dict(zip(TreeStatus.__status_types,
(self.__enter_mitosis, self.__enter_mitosis_frame, self.__division_event_happen,
self.__division_count, self.__exit_mitosis, self.__exit_mitosis_frame)))
[docs] def get_status(self, status_type: str):
"""
:param status_type: __status_types member
:return: member status
"""
return self.status.get(status_type)
[docs] def check_division_window(self):
"""
Check if the cell is in the division window stage
"""
if self.division_windows_len > 0:
return True
return False
[docs] def reset_division_window(self):
"""
reset the division_windows_len
"""
self.division_windows_len = 20
[docs] def sub_division_window(self):
"""
When the cell is in the division window stage, continuously decreases the division_windows_len
"""
if self.division_windows_len > 0:
self.division_windows_len -= 1
[docs] def is_in_division_window(self):
if self.division_windows_len > 0:
return True
return False
[docs] def enter_mitosis(self, frame):
if self.__exit_mitosis_time >= self.enter_mitosis_threshold:
self.__enter_mitosis = True
self.__exit_mitosis = False
self.__exit_mitosis_time = 0
self.__enter_mitosis_frame = frame
self.__exit_mitosis_frame = None
self.reset_division_window()
[docs] def exit_mitosis(self, frame):
self.__enter_mitosis = False
self.__exit_mitosis = True
self.__exit_mitosis_frame = frame
self.__division_event_happen = True
self.__division_count += 1
[docs] def set_matched_status(self, value):
if value == 'matched':
self.__match_status = MatchStatus.Matched
elif value == 'loss':
self.__match_status = MatchStatus.LossMatch
else:
pass
[docs] def add_M_count(self):
self.__predict_M_count += 1
[docs] def reset_M_count(self):
self.__predict_M_count = 0
@property
def predict_M_len(self):
return self.__predict_M_count
[docs] def add_exist_time(self):
if self.__exit_mitosis:
self.__exit_mitosis_time += 1
@property
def is_mitosis_enter(self):
return self.__enter_mitosis
@property
def exit_mitosis_time(self):
return self.__exit_mitosis_time
def __str__(self):
return str(self.status)
def __repr__(self):
return self.__str__()
[docs]class CellStatus(TreeStatus):
"""
Record the status of cells and exclude them from other matching candidates if they have participated in precise matching.
If the cell has undergone Mitosis in a short time, it will not participate in Mitosis matching.
"""
pass
[docs]class SingleInstance(object):
"""Singleton pattern base class. If the parameters are the same, only one instance object will be instantiated"""
_instances = {}
init_flag = False
def __new__(cls, *args, **kwargs):
key = str(args) + str(kwargs)
if key not in cls._instances:
cls._instances[key] = super().__new__(cls)
SingleInstance.__init__(SingleInstance._instances[key], *args, **kwargs)
return cls._instances[key]
def __init__(self, *args, **kwargs):
pass
[docs]class Rectangle(object):
"""
Rectangular class , used to record the bounding box and available range of cells
"""
def __init__(self, x_min, x_max, y_min, y_max):
self.x_min = x_min
self.x_max = x_max
self.y_min = y_min
self.y_max = y_max
@property
def area(self):
"""
Return the Rectangle area,
"""
return abs((self.x_max - self.x_min) * (self.y_max - self.y_min))
def _intersectX(self, other):
"""
Determine whether two rectangles intersect on the X-axis
"""
if max(self.x_min, other.x_min) - min(self.x_max, other.x_max) >= 0:
return False
else:
return True
def _intersectY(self, other):
"""Determine whether two rectangles intersect on the Y-axis"""
if max(self.y_min, other.y_min) - min(self.y_max, other.y_max) >= 0:
return False
else:
return True
def _include(self, other, self_max, other_max, self_min, other_min):
"""
Determine if it contains, incoming x value represents in the x-axis direction, incoming y value represents in the y-axis direction
"""
if self_min >= other_min:
if self_max <= other_max:
flag = other
return True, flag # indicate longer instance
else:
return False
else:
if self_min <= other_min:
if self_max >= other_max:
flag = self
return True, flag
else:
return False
def _includeX(self, other):
"""
Determine if there is an inclusion relationship between the X-axis of two rectangles
"""
return self._include(other, self.x_max, other.x_max, self.x_min, other.x_min)
def _includeY(self, other):
"""
Determine if there is an inclusion relationship between the Y-axis of two rectangles
"""
return self._include(other, self.y_max, other.y_max, self.y_min, other.y_min)
[docs] def isIntersect(self, other):
"""
Determine whether two rectangles intersect
"""
if self._intersectX(other) and self._intersectY(other):
return True
else:
return False
[docs] def isInclude(self, other):
"""
Determine whether two rectangles contain
"""
if not self._includeX(other):
return False
else:
if not self._includeY(other):
return False
else:
if self._includeX(other)[1] != self._includeY(other)[1]:
return False
else:
return True
[docs] def draw(self, background=None, isShow=False, color=(255, 0, 0)):
"""
draw the rectangle
"""
import cv2
if background is not None:
_background = background
else:
_background = np.zeros(shape=(100, 100))
if len(_background.shape) == 2:
_background = cv2.cvtColor(convert_dtype(_background), cv2.COLOR_GRAY2RGB)
# (bbox[2], bbox[0]), (bbox[3], bbox[1])
cv2.rectangle(_background, (self.y_min, self.x_min), (self.y_max, self.x_max), color, 2)
if isShow:
cv2.imshow('rec', _background)
cv2.waitKey()
return _background
[docs]class Vector(np.ndarray):
"""
2D plane vector class
"""
def __new__(cls, x=None, y=None, shape=(2,), dtype=float, buffer=None, offset=0,
strides=None, order=None):
obj = super().__new__(cls, shape, dtype,
buffer, offset, strides, order)
if x is None and y is None:
obj.xy = None
obj.x = x
obj.y = y
elif x is None and y:
obj.xy = [0, y]
obj.x = 0
obj.y = y
elif x and y is None:
obj.xy = [x, 0]
obj.x = x
obj.y = 0
else:
obj.xy = [x, y]
obj.x = x
obj.y = y
return obj
def __array_finalize__(self, obj):
if obj is None:
return
self.info = getattr(obj, 'xy', None)
self.x = getattr(obj, 'x', None)
self.y = getattr(obj, 'y', None)
@property
def module(self):
"""
Return the modulus of vector
"""
if self.xy is None:
return 0
return np.linalg.norm([self.x, self.y])
@property
def cos(self):
"""
Return the cosine value of vector
"""
if not self.xy:
return None
return self.x / self.module
[docs] def cosSimilar(self, other):
"""
Compare the cosine similarity of two vectors, with a range of values [-1, 1]
"""
if self.xy is None:
if other.xy is None:
return None
elif other == Vector(0, 0):
return 0
else:
return other.cos
if self == other:
return 1
if other == Vector(0, 0):
return 0
if self == Vector(0, 0):
if other.yx is None:
return 0
elif other == Vector(0, 0):
return 1
else:
return other.cos
return np.dot(self.xy, other.xy) / (self.module * other.module)
[docs] def cosDistance(self, other):
"""
Cosine distance, numerically equal to 1 minus cosine similarity, range of values [0,2]
"""
return 1 - self.cosSimilar(other)
[docs] def EuclideanDistance(self, other):
"""
Return the Euclidean distance between two vectors
"""
return np.sqrt((abs(self.x - other.x) ** 2 + abs(self.y - other.y) ** 2))
def __len__(self):
if self.xy is None:
return 0
return len(self.xy)
def __str__(self):
if self.xy is None:
return "None Vector"
return str(self.xy)
def __repr__(self):
return self.__str__()
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __lt__(self, other):
return self.module < other.module
def __bool__(self):
if not self.xy or self == Vector(0, 0):
return False
return True
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)
def __neg__(self):
return Vector(-self.x, -self.y)
def __mul__(self, other):
if hasattr(other, '__float__'):
return Vector(self.x * other, self.y * other)
elif isinstance(other, Vector):
return self.x * other.x + self.y * other.y
else:
raise TypeError(f'{type(other)} are not support the Multiplication operation with type Vector!')
[docs]class Cell(object):
"""
Define the cell class, which is the core class of SC-Track. The smallest unit of all operations is the Cell instance,
and the Cell is implemented as a conditional singleton mode: that is, different objects are generated according to
the parameters passed in. If the parameters passed in are the same , only one object is instantiated. When defining
the Cell object, Cell class can be guaranteed that there is only one Cell instance for the same cell in one frame,
and differentCell instances are generated in different frames.
If you want to instantiate a Cell object, at least its position information needs to be passed in, that is, all the
xy coordinate points that constitute the outline of the cell.
"""
_instances = {}
def __new__(cls, *args, **kwargs):
key = str(args) + str(kwargs)
if key not in cls._instances:
cls._instances[key] = super(Cell, cls).__new__(cls)
cls._instances[key].__feature = None
cls._instances[key].sort_value = 0
cls._instances[key].__feature_flag = False
cls._instances[key].__track_id = None
cls._instances[key].__branch_id = None
cls._instances[key].__inaccurate_track_id = None
cls._instances[key].__is_track_id_changeable = True
cls._instances[key].mitosis_start_flag = False
cls._instances[key].__region = None
cls._instances[key].__status = None
cls._instances[key].__is_accurate_matched = False
cls._instances[key].__match_status = False # 匹配状态,如果参与匹配则设置为匹配状态,从未匹配则设置为False
return cls._instances[key]
def __init__(self, position=None, mcy=None, dic=None, cell_type=None, frame_index=None, flag=None):
# if Cell.init_flag is False:
self.position = position # [(x1, x2 ... xn), (y1, y2 ... yn)]
self.mcy = mcy
self.dic = dic
self.cell_type = cell_type
self.__id = None
self.frame = frame_index
self.__parent = None # If a cell divides, record the __id of the cell's parent
self.__move_speed = Vector(0, 0)
self.polygon = Polygon([xy for xy in zip(*self.position)])
if flag is None:
self.flag = 'cell'
else:
self.flag = 'gap'
Cell.init_flag = True
# else:
# return
[docs] def change_mitosis_flag(self, flag: bool):
"""
When the cell enters mitosis for the first time, self.mitosis_start_flag is set to True, and when the cell
completes division, it is reset to false.
"""
self.mitosis_start_flag = flag
@property
@lru_cache(maxsize=None)
def contours(self):
"""
Convert the list of xy coordinate points into contour points list
:return: if successful, return contours, else return None
"""
points = []
if self.position:
for j in range(len(self.position[0])):
x = int(self.position[0][j])
y = int(self.position[1][j])
points.append((x, y))
contours = np.array(points)
return contours
else:
return None
@property
def move_speed(self) -> Vector:
"""
:return: moving speed of the cell, the Vector object type
"""
return self.__move_speed
[docs] def update_speed(self, speed: Vector):
"""
:param speed: new speed of the cell, Vector instance.
:return: None
"""
self.__move_speed = speed
[docs] @staticmethod
def polygon_centroid(vertex_coordinates):
"""
Calculate the physical center of gravity of the cell counters.
:param vertex_coordinates: list of cell outline coordinate points, the format is [[x1, x2,...xn], [y1, y2,..yn]]
:return: physical center of gravity
"""
x_coords = vertex_coordinates[0]
y_coords = vertex_coordinates[1]
n = len(x_coords)
area = 0.0
centroid_x = 0.0
centroid_y = 0.0
for i in range(n):
j = (i + 1) % n
cross_product = x_coords[i] * y_coords[j] - x_coords[j] * y_coords[i]
area += cross_product
centroid_x += (x_coords[i] + x_coords[j]) * cross_product
centroid_y += (y_coords[i] + y_coords[j]) * cross_product
area /= 2.0
centroid_x /= 6.0 * area
centroid_y /= 6.0 * area
return centroid_x, centroid_y
@property
@lru_cache(maxsize=None)
def center(self):
"""
:return: cell physical center
"""
# return np.mean(self.position[0]), np.mean(self.position[1])
return self.polygon_centroid(self.position)
@property
@lru_cache(maxsize=None)
def available_range(self) -> Rectangle:
"""
Define the matchable range of the two frames before and after, the default is twice the horizontal and vertical
coordinates of the cell
:return: Rectangle instance
"""
mult = config.AVAILABLE_RANGE_COEFFICIENT
x_len = self.bbox[3] - self.bbox[2]
y_len = self.bbox[1] - self.bbox[0]
x_min_expand = self.bbox[2] - mult * x_len
x_max_expand = self.bbox[3] + mult * x_len
y_min_expand = self.bbox[0] - mult * y_len
y_max_expand = self.bbox[1] + mult * y_len
return Rectangle(y_min_expand, y_max_expand, x_min_expand, x_max_expand)
@property
def r_long(self):
"""
:return: Cell bounding box long side radius
"""
return max((self.bbox[3] - self.bbox[2]) / 2, (self.bbox[1] - self.bbox[0]) / 2)
@property
def r_short(self):
"""
:return: Cell bounding box short side radius
"""
return min((self.bbox[3] - self.bbox[2]) / 2, (self.bbox[1] - self.bbox[0]) / 2)
@property
def d_long(self):
"""
:return: Long side diameter of cell bounding box
"""
return 2 * self.r_long
@property
def d_short(self):
"""
:return: Short side diameter of cell bounding box
"""
return 2 * self.r_short
[docs] @staticmethod
@lru_cache(maxsize=None)
def polygon_area(x, y):
"""
Calculate cell area
:param x: A list of x-coordinates of all points of the cell counters
:param y: A list of y-coordinates of all points of the cell counters
:return: Area of cell
"""
return 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
@property
@lru_cache(maxsize=None)
def vector(self) -> Vector:
"""
Returns the cell center point vector, starting from the upper left corner of the image as the origin.
:return: Vector instance
"""
return Vector(*self.center)
@property
def area(self):
"""
:return: Area of a cell
"""
return self.polygon_area(tuple(self.position[0]), tuple(self.position[1]))
[docs] def set_region(self, region):
self.__region = region
[docs] def set_status(self, status: TreeStatus | CellStatus):
self.__status = status
@property
def status(self):
return self.__status
@property
def is_accurate_matched(self):
return self.__is_accurate_matched
@is_accurate_matched.setter
def is_accurate_matched(self, value: bool):
self.__is_accurate_matched = value
@property
def region(self):
return self.__region
@property
@lru_cache(maxsize=None)
def bbox(self):
"""bounding box coordinates"""
x0 = math.floor(np.min(self.position[0])) if math.floor(np.min(self.position[0])) > 0 else 0
x1 = math.ceil(np.max(self.position[0]))
y0 = math.floor(np.min(self.position[1])) if math.floor(np.min(self.position[1])) > 0 else 0
y1 = math.ceil(np.max(self.position[1]))
return y0, y1, x0, x1
[docs] def move(self, speed: Vector, time: int = 1):
"""
:param speed: move speed, Vector object instance
:param time: move time,frame
:return: New Cell instance after moving
"""
new_position = [tuple([i + speed.x * time for i in self.position[0]]),
tuple([j + speed.y * time for j in self.position[1]])]
new_cell = Cell(position=new_position, mcy=self.mcy, dic=self.dic, cell_type=self.cell_type, frame_index=self.frame)
new_cell.set_track_id(self.__track_id, 0)
return new_cell
[docs] def set_feature(self, feature):
"""set Feature object for cell"""
self.__feature = feature
self.__feature_flag = True
@property
def feature(self):
if self.__feature_flag:
return self.__feature
else:
raise ValueError("No available feature! ")
[docs] def set_track_id(self, __track_id, status: 0 | 1):
"""Set the track_id for the cell"""
if self.__is_track_id_changeable:
if status == 1:
self.__track_id = __track_id
self.__is_track_id_changeable = False
elif status == 0:
self.__inaccurate_track_id = __track_id
else:
raise ValueError(f'status {status} is invalid!')
else:
# warnings.warn('cannot change the accurate track_id')
pass
[docs] def set_match_status(self, status: bool | str):
self.__match_status = status
@property
def is_be_matched(self):
"""I
f participated in the match, return match status, otherwise, False
"""
return self.__match_status
@is_be_matched.setter
def is_be_matched(self, value):
self.__match_status = value
[docs] def set_parent_id(self, __parent_id):
self.__parent = __parent_id
[docs] def set_cell_id(self, cell_id):
self.__id = cell_id
[docs] def set_branch_id(self, branch_id):
self.__branch_id = branch_id
[docs] def update_region(self, **kwargs):
"""update annotation region information ,add the tracking results."""
new_region = self.region
if new_region:
if 'branch_id' in kwargs:
new_region['region_attributes']['branch_id'] = kwargs['branch_id']
if 'track_id' in kwargs:
new_region['region_attributes']['track_id'] = kwargs['track_id']
self.set_region(new_region)
@property
def branch_id(self):
return self.__branch_id
@property
def cell_id(self):
"""Cell id, parent cell and daughter cell have different value"""
return self.__id
@property
def parent(self):
return self.__parent
@property
def track_id(self):
return self.__track_id
[docs] def draw(self, background=None, isShow=False, color=(255, 0, 0)):
import cv2
if background is not None:
_background = background
else:
_background = np.ones(shape=(2048, 2048, 3), dtype=np.uint8)
if len(_background.shape) == 2:
_background = cv2.cvtColor(convert_dtype(_background), cv2.COLOR_GRAY2RGB)
# cv2.drawContours(_background, self.contours, -1, color, 3)
cv2.rectangle(_background, (self.bbox[2], self.bbox[0]), (self.bbox[3], self.bbox[1]), color, 5)
if isShow:
cv2.imshow('rec', _background)
cv2.resizeWindow('rec', 500, 500)
cv2.waitKey()
return _background
def __contains__(self, item):
return True if self.available_range.isIntersect(Rectangle(*item.bbox)) else False
def __str__(self):
if self.position:
return f" Cell at ({self.center[0]: .2f},{self.center[1]: .2f}), frame {self.frame}, {self.cell_type}, branch {self.__branch_id}"
else:
return "Object Cell"
def __repr__(self):
return self.__str__()
def __lt__(self, other):
if self.position and other.position:
self_module = np.linalg.norm([np.mean(self.position[0]), np.mean(self.position[1])])
other_module = np.linalg.norm([np.mean(other.position[0]), np.mean(other.position[1])])
return self_module < other_module
else:
raise ValueError("exist None object of ")
def __eq__(self, other):
return self.position == other.position and self.frame == other.frame
def __hash__(self):
return id(self)