# This file is part of idtracker.ai a multiple animals tracking system
# described in [1].
# Copyright (C) 2017- Francisco Romero Ferrero, Mattia G. Bergomi,
# Francisco J.H. Heras, Robert Hinz, Gonzalo G. de Polavieja and the
# Champalimaud Foundation.
#
# idtracker.ai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details. In addition, we require
# derivatives or applications to acknowledge the authors by citing [1].
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
# For more information please send an email (idtrackerai@gmail.com) or
# use the tools available at https://gitlab.com/polavieja_lab/idtrackerai.git.
#
# [1] Romero-Ferrero, F., Bergomi, M.G., Hinz, R.C., Heras, F.J.H.,
# de Polavieja, G.G., Nature Methods, 2019.
# idtracker.ai: tracking all individuals in small or large collectives of
# unmarked animals.
# (F.R.-F. and M.G.B. contributed equally to this work.
# Correspondence should be addressed to G.G.d.P:
# gonzalo.polavieja@neuro.fchampalimaud.org)
from functools import cached_property
from itertools import chain
from math import atan2, sqrt
from pathlib import Path
from typing import Iterable, Sequence

import cv2
import h5py
import numpy as np


class Blob:
    """Represents a segmented blob (collection of pixels) from a given frame.

    A blob can represent a single animal or multiple animals during an
    occlusion or crossing.

    Parameters
    ----------

    contour : numpy array
        Array with the points that define the contour of the blob. The
        array is of the form [[[x1,y1]],[[x2,y2]],...,[[xn,yn]]].
    estimated_body_length : float, optional
        Body length of the animal estimated from the diagonal of the
        original bounding box, by default None.
    pixels : list, optional
        List of pixels that define the blob, by default None.
    number_of_animals : int, optional
        Number of animals in the video as defined by the user,
        by default None.
    frame_number : int, optional
        Frame number in the video from which the blob was segmented,
        by default None.
    frame_number_in_video_path : int, optional
        Frame number in the video path from which the blob was segmented,
        by default None.
    in_frame_index : int, optional
        Index of the blob in the frame where it was segmented,
        by default None. This index comes from OpenCV and is defined by the
        hierarchy of the countours found in the frame.
    pixels_are_from_eroded_blob : bool, optional
        Flag to indicate if the pixels of the blobs come from from an
        eroded blob, by default False.
    resolution_reduction : float, optional
        Resolution reductio factor as defined by the user, by default 1.0.
    """

    episode: int
    id_image_index: int
    """Index of the identification image position in the hdf5 file"""

    seems_like_individual: bool = False
    """Unicity condition or not huge area"""

    is_an_individual: bool
    """Flag indicating the blob represents a single animal.
    Defined in crossing detection."""

    used_for_training_crossings: bool = False
    """Flag indicating if the blob has been used to train the
    crossing CNN"""

    was_a_crossing: bool = False
    """Flag indicating whether the blob was created after splitting a
    crossing blob during the crossings interpolation process"""

    identity: int | None = None
    """Identity of the blob assigned during the identification process"""

    next: list["Blob"]
    previous: list["Blob"]

    fragment_identifier: int = -1
    """Indicates the index of the Fragment that contains the blob,
    -1 means no associated Fragment"""

    pixels_are_from_eroded_blob: bool = False

    blob_index: int = None  # type: ignore
    """Blob index at the segmentation step (comes from the find contours
    function of OpenCV)"""

    user_generated_identities: list[int | None] = None  # type: ignore
    """List of identities of the blob some of which might have been give
    by a user during the validation process"""

    user_generated_identity: int | None = None
    """This property is give during the correction of impossible velocity
    jumps. It has nothing to do with the manual validation."""

    user_generated_centroids: list[tuple[float, float] | None] = None  # type: ignore
    """List of centroids generated by the user during the validation
    processes"""

    identity_corrected_solving_jumps: int | None = None
    """Identity of the blob after correcting impossible velocity jumps"""

    identities_corrected_closing_gaps: list | None = None
    """Identity of the blob after crossings interpolation"""

    interpolated_centroids: list | None = None

    added_by_user: bool = False

    forced_crossing: bool = False
    """Indicates if the crossing attribute has been forced by set_individual_with_identity_0_as_crossings()"""

    def __init__(
        self,
        contour: np.ndarray,
        frame_number: int = -1,
        bbox_img_id: str = "",
        pixels_are_from_eroded_blob: bool = False,
    ):
        self.set_contour(contour)
        self.frame_number = frame_number
        self.bbox_img_id = bbox_img_id
        if pixels_are_from_eroded_blob:
            self.pixels_are_from_eroded_blob = pixels_are_from_eroded_blob
            # TODO fix this, some eroded blobs are not classified as
            # individuals/crossings and idtrackerai crashes
            self.is_an_individual = True
            self.forced_crossing = True

        self.next = []
        self.previous = []

    @property
    def n_next(self):
        return len(self.next)

    @property
    def n_previous(self):
        return len(self.previous)

    @cached_property
    def convexHull(self) -> np.ndarray:
        return cv2.convexHull(self.contour)

    @cached_property
    def area(self) -> int:
        return cv2.contourArea(self.contour)

    @cached_property
    def bbox_in_frame_coordinates(self) -> tuple[tuple[int, int], tuple[int, int]]:
        return self.contour.min(0), self.contour.max(0)

    @property
    def estimated_body_length(self):
        width, height = np.ptp(self.contour, axis=0)
        return int(np.ceil(sqrt(width**2 + height**2)))

    @cached_property
    def centroid(self) -> tuple[float, float]:
        M = cv2.moments(self.contour)
        try:
            return M["m10"] / M["m00"], M["m01"] / M["m00"]
        except ZeroDivisionError:
            return tuple(np.mean(self.contour, axis=0))

    @cached_property
    def orientation(self) -> float:
        M = cv2.moments(self.contour)
        try:
            x = M["m10"] / M["m00"]
            y = M["m01"] / M["m00"]
            a = M["m20"] / M["m00"] - x * x
            b = 2 * (M["m11"] / M["m00"] - x * y)
            c = M["m02"] / M["m00"] - y * y
            return 0.5 * atan2(b, (a - c))
        except ZeroDivisionError:
            return 0

    def set_contour(self, contour: np.ndarray):
        if contour.ndim == 3 and contour.shape[1] == 1:
            # OpenCV returns contours as (n_points, 1, 2)
            contour = contour[:, 0, :]
        self.contour = contour.astype(np.int32, copy=False)

    @property
    def is_a_crossing(self) -> bool:
        """Flag indicating whether the blob represents two or more animals
        together.

        This attribute is the negative of `is_an_individual` and is set at
        the same time as is an individual
        """
        return not self.is_an_individual

    def has_multiple_previous(self) -> bool:
        """Flag indicating if the blob has multiple blobs in its past or future
        overlapping history

        This method is used to check whether the blob is a crossing.

        Returns
        -------
        bool
            If True the blob splits into two or multiple overlapping blobs in
            its "past" or "future" history, depending on the parameter
            "direction".
        """

        current = self.previous[0]
        while len(current.previous) == 1:
            current = current.previous[0]
            if len(current.previous) > 1:
                return True

        return False

    def has_multiple_next(self) -> bool:
        """Flag indicating if the blob has multiple blobs in its past or future
        overlapping history

        This method is used to check whether the blob is a crossing.

        Returns
        -------
        bool
            If True the blob splits into two or multiple overlapping blobs in
            its "past" or "future" history, depending on the parameter
            "direction".
        """

        current = self.next[0]
        while len(current.next) == 1:
            current = current.next[0]
            if len(current.next) > 1:
                return True

        return False

    def check_for_crossing_next(self) -> bool:
        """Flag indicating if the blob has a crossing in its future overlapping history

        Returns
        -------
        bool
            If True the blob has a crossing in its "future" history
        """
        current = self.next[0]
        while current.n_next == 1:
            current = current.next[0]
            if current.n_previous > 1 and not current.seems_like_individual:
                return True
        return False

    def check_for_crossing_previous(self) -> bool:
        """Flag indicating if the blob has a crossing in its past overlapping history

        Returns
        -------
        bool
            If True the blob has a crossing in its "past" history
        """
        current = self.previous[0]
        while current.n_previous == 1:
            current = current.previous[0]
            if current.n_next > 1 and not current.seems_like_individual:
                return True
        return False

    def is_a_sure_individual(self) -> bool:
        """Flag indicating that the blob is a sure individual according to
        some heuristics and it can be used to train the crossing detector CNN.
        """
        return (
            self.seems_like_individual
            and len(self.previous) == 1
            and len(self.next) == 1
            and len(self.next[0].previous) == 1
            and len(self.previous[0].next) == 1
            and self.check_for_crossing_previous()
            and self.check_for_crossing_next()
        )

    def is_a_sure_crossing(self) -> bool:
        """Flag indicating that the blob is a sure crossing according to
        some heuristics and it can be used to train the crossing detector CNN.

        Returns
        -------
        bool
        """
        if self.seems_like_individual:
            return False
        if len(self.previous) > 1 or len(self.next) > 1:
            return True
        if len(self.previous) == 1 and len(self.next) == 1:
            return self.has_multiple_previous() and self.has_multiple_next()
        return False

    def overlaps_with(self, other: "Blob") -> bool:
        """Computes whether the pixels in `self` intersect with the pixels in
        `other`

        Parameters
        ----------
        other : <Blob object>
            An instance of the class Blob

        Returns
        -------
        bool
            True if the lists of pixels of both blobs have non-empty
            intersection
        """

        # Check bounding boxes
        (S_xmin, S_ymin), (S_xmax, S_ymax) = self.bbox_in_frame_coordinates
        (O_xmin, O_ymin), (O_xmax, O_ymax) = other.bbox_in_frame_coordinates

        if not S_xmax >= O_xmin and O_xmax >= S_xmin:  # x overlap
            return False
        if not S_ymax >= O_ymin and O_ymax >= S_ymin:  # y overlap
            return False

        # Check convex hull
        if not cv2.intersectConvexConvex(self.convexHull, other.convexHull)[0]:
            return False

        # Check for every point in `other`'s contour
        points = other.contour.astype(float)
        for point in chain(points[0::3], points[1::3], points[2::3]):
            if self.contour_contains_point(point):
                return True

        # Check for every point in `self`'s contour
        points = self.contour.astype(float)
        return any(
            other.contour_contains_point(point)
            for point in chain(points[0::3], points[1::3], points[2::3])
        )

    def contour_contains_point(self, point: tuple[float, float]) -> bool:
        return cv2.pointPolygonTest(self.contour, point, False) >= 0

    def bbox_contains_point(self, point: tuple[float, float]) -> bool:
        return (
            point[0] >= self.bbox_in_frame_coordinates[0][0]
            and point[0] <= self.bbox_in_frame_coordinates[1][0]
            and point[1] >= self.bbox_in_frame_coordinates[0][1]
            and point[1] <= self.bbox_in_frame_coordinates[1][1]
        )

    def contains_point(self, point: tuple[float, float]) -> bool:
        if not self.bbox_contains_point(point):
            return False
        return self.contour_contains_point(point)

    def now_points_to(self, other: "Blob"):
        """Given two consecutive blob objects updates their respective
        overlapping histories

        Parameters
        ----------
        other : <Blob object>
            An instance of the class Blob
        """
        self.next.append(other)
        other.previous.append(self)

    def square_distance_to(self, other: "Blob|tuple|list|np.ndarray"):
        """Returns the squared distance from the centroid of self to the
        centroid of `other`

        Parameters
        ----------
        other : <Blob object> or tuple
            An instance of the class Blob or a tuple (x,y)

        Returns
        -------
        float
            Squared distance between centroids
        """
        if isinstance(other, Blob):
            return ((np.asarray(self.centroid) - np.asarray(other.centroid)) ** 2).sum()

        if isinstance(other, (tuple, list, np.ndarray)):
            return ((np.asarray(self.centroid) - np.asarray(other)) ** 2).sum()

        raise ValueError

    def distance_to(self, other: "Blob|tuple|list|np.ndarray") -> float:
        return sqrt(self.square_distance_to(other))

    def distance_from_countour_to(self, point):
        """Returns the distance between `point` and the closest
        point in the contour of the blob.

        Parameters
        ----------
        point : tuple
            (x,y)

        Returns
        -------
        float
            Smallest distance between `point` and the contour of the blob.
        """
        return abs(cv2.pointPolygonTest(self.contour, point, True))

    @property
    def assigned_identities(self):
        """Identities assigned to the blob during the tracking process"""
        if self.identities_corrected_closing_gaps is not None:
            return self.identities_corrected_closing_gaps
        if self.identity_corrected_solving_jumps is not None:
            return [self.identity_corrected_solving_jumps]
        return [self.identity]

    @property
    def assigned_centroids(self):
        """Centroids assigned to the blob during the tracking process.

        It considers the default centroid of the blob at segmentation time
        or new centroids added to the blob during the interpolation of the
        crossings.

        Returns
        -------
        list
            List of pairs (x, y) indicating the position of each individual
            in the blob.
        """
        if self.interpolated_centroids:
            return self.interpolated_centroids
        return [self.centroid]

    @property
    def final_centroids(self):
        """List of the animal/s centroid/s in the blob, considering the
        potential centroids that might have been added by the user during
        the validation.

        By default the centroid will be the center of mass of the blob of
        pixels defined by the blob. It can be different if the user modified
        the default centroid during validation or generated more centroids.

        Returns
        -------
        list
            List of tuples (x, y) indicating the centroids of the blob.
        """
        return (c for c in self.all_final_centroids if c != (-1, -1))

    @property
    def all_final_centroids(self):
        if self.user_generated_centroids:
            # Note that sometimes len(user_generated_centroids) >
            # len(assigned_centroids)
            final_centroids = []
            for i, centroid in enumerate(self.user_generated_centroids):
                if centroid is not None or i >= len(self.assigned_centroids):
                    final_centroids.append(centroid)
                else:
                    final_centroids.append(self.assigned_centroids[i])
            return final_centroids
        return self.assigned_centroids

    @property
    def final_identities(self):
        return (id for id in self.all_final_identities if id != -1)

    @property
    def all_final_identities(self):
        """Identities of the blob after the tracking process and after
        potential modifications by the users during the validation procedure.
        """
        if self.user_generated_identities:
            # Note that sometimes len(user_generated_identities)
            # > len(assigned_identities)
            final_identities = []
            # TODO None means the same as assigned, 0 means no id, -1 means no centroid
            for i, user_generated_identity in enumerate(self.user_generated_identities):
                if user_generated_identity is not None or i >= len(
                    self.assigned_identities
                ):
                    final_identities.append(user_generated_identity)
                else:
                    final_identities.append(self.assigned_identities[i])
            return final_identities
        return self.assigned_identities

    @property
    def final_ids_and_centroids(
        self,
    ) -> Iterable[tuple[int | None, tuple[float, float]]]:
        return zip(self.final_identities, self.final_centroids)

    @property
    def all_final_ids_and_centroids(self):
        return zip(self.all_final_identities, self.all_final_centroids)

    def get_image_for_identification(
        self, img_size: int, bbox_imgs_path: Path
    ) -> np.ndarray:
        """Gets the image used to train and evaluate the crossing detector CNN
        and the identification CNN.

        Parameters
        ----------
        id_image_size : tuple
            Dimensions of the identification image (height, widht, channels).
            Channels is always 1 as images in color are still not considered.
        height : int
            Video height considering resolution reduction factor.
        width : int
            Video width considering resolution reduction factor.

        Returns
        -------
        ndarray
            Square image with black background used to train the crossings
            detector CNN and the identifiactio CNN.

        It generates the image that will be used to train and evaluate the
        crossings detector CNN and the identification CNN.

        Parameters
        ----------
        height : int
            Frame height
        width : int
            Frame width
        bbox_image : ndarray
            Images cropped from the frame by considering the bounding box
            associated to a blob
        pixels : list
            List of pixels associated to a blob
        bbox_in_frame_coordinates : list
            [(x, y), (x + bbox_width, y + bbox_height)]
        image_size : int
            Size of the width and height of the square identification image

        Returns
        -------
        ndarray
            Square image with black background used to train the crossings
            detector CNN and the identification CNN.
        """

        with h5py.File(bbox_imgs_path, "r") as f:
            bbox_img = f[self.bbox_img_id][:]  # type: ignore #

        mask = self.get_bbox_mask()

        mask = cv2.dilate(mask, np.ones((3, 3), np.uint8), iterations=1)

        masked_bbox_image = bbox_img * mask
        bbox_img_height, bbox_img_width = masked_bbox_image.shape
        img_size2 = img_size % 2 + img_size // 2

        center_x = int(
            self.centroid[0]
            - self.bbox_in_frame_coordinates[0][0]
            + 1  # bbox_image_pad
        )

        center_y = int(
            self.centroid[1]
            - self.bbox_in_frame_coordinates[0][1]
            + 1  # bbox_image_pad
        )

        d1 = center_x**2 + center_y**2
        d2 = center_x**2 + (bbox_img_height - center_y) ** 2
        d3 = (bbox_img_width - center_x) ** 2 + center_y**2
        d4 = (bbox_img_width - center_x) ** 2 + (bbox_img_height - center_y) ** 2
        diag = int(sqrt(max((d1, d2, d3, d4))))
        diag = max(diag, img_size2)
        id_img = np.zeros((2 * diag, 2 * diag), np.uint8)
        id_img[
            diag - center_y : diag + bbox_img_height - center_y,
            diag - center_x : diag + bbox_img_width - center_x,
        ] = masked_bbox_image

        M = cv2.getRotationMatrix2D(
            (diag, diag), self.orientation * 180 / np.pi - 45, 1
        )

        # old method
        id_img = cv2.warpAffine(
            src=id_img,
            M=M,
            dsize=(diag + img_size2, diag + img_size2),
            borderMode=cv2.BORDER_CONSTANT,
            flags=cv2.INTER_CUBIC,
        )
        return id_img[-img_size:, -img_size:]

        # TODO proposed future method
        # id_img = cv2.warpAffine(
        #     src=id_img,
        #     M=M,
        #     dsize=(diag + img_size, diag + img_size),
        #     borderMode=cv2.BORDER_CONSTANT,
        #     flags=cv2.INTER_CUBIC,
        # )

        # # we build the offset like this to have the minimal ones on the
        # # beginning of the array and be preferably selected by max()
        # origins = [0]
        # for offset in range(img_size2 // 2):
        #     origins += (diag - img_size2 + offset, diag - img_size2 - offset)

        # origin = max(
        #     origins,
        #     key=lambda origin: np.count_nonzero(
        #         id_img[origin : origin + img_size, origin : origin + img_size]
        #     ),
        # )

        # return id_img[origin : origin + img_size, origin : origin + img_size]

    def get_bbox_mask(self) -> np.ndarray:
        base = np.zeros(
            (
                self.bbox_in_frame_coordinates[1][1]
                - self.bbox_in_frame_coordinates[0][1]
                + 2,  # 2 bbox_image_pads
                self.bbox_in_frame_coordinates[1][0]
                - self.bbox_in_frame_coordinates[0][0]
                + 2,  # 2 bbox_image_pads
            ),
            np.uint8,
        )
        return cv2.fillPoly(
            img=base,
            pts=(self.contour,),
            color=1,
            offset=(
                1 - self.bbox_in_frame_coordinates[0][0],  # bbox_image_pad
                1 - self.bbox_in_frame_coordinates[0][1],  # bbox_image_pad
            ),
        )

    def update_centroid(
        self,
        old_centroid: tuple[float, float],
        new_centroid: tuple[float, float],
        identity: int,
    ):
        """[Validation] Updates the centroid of the blob.

        Parameters
        ----------
        video : idtrackerai.video.Video
            Instance of Video object
        old_centroid : tuple
            Centroid to be updated
        new_centroid : tuple
            Coordinates of the new centroid
        identity : int
            Identity of the centroid to be updated
        """

        self.init_validator_variables()
        index, centroid, dist = self.index_and_centroid_closer_to(
            old_centroid, identity
        )

        self.user_generated_centroids[index] = new_centroid
        self.user_generated_identities[index] = identity

    def init_validator_variables(self):
        if self.user_generated_centroids is None:
            self.user_generated_centroids: list[tuple[float, float] | None] = [
                None
            ] * len(list(self.final_centroids))
        if self.user_generated_identities is None:
            self.user_generated_identities = [None] * len(list(self.final_identities))

    def index_and_centroid_closer_to(
        self, centroid: tuple, identity: int | None
    ) -> tuple[int, tuple, float]:
        candidates: list[tuple[int, tuple, float]] = []
        for indx, (_id, _centroid) in enumerate(self.all_final_ids_and_centroids):
            if identity not in (None, _id):
                continue
            dist = (_centroid[0] - centroid[0]) ** 2 + (_centroid[1] - centroid[1]) ** 2
            candidates.append((indx, _centroid, dist))
        if not candidates:
            raise ValueError("Centroid not found")

        return min(candidates, key=lambda x: x[0])

    def remove_centroid(self, identity: int, centroid: tuple):
        """[Validation] Deletes a centroid of the blob.

        Parameters
        ----------
        identity : int
            Identity of the centroid to be deleted
        centroid : tuple
            Centroid to be deleted from the blob
        """

        self.init_validator_variables()
        index, centroid, dist = self.index_and_centroid_closer_to(centroid, identity)

        self.user_generated_centroids[index] = (-1, -1)
        self.user_generated_identities[index] = -1

    def add_centroid(self, centroid: tuple[float, float], identity: int | None):
        """[Validation] Adds a centroid with a given identity to the blob.

        This method is used in the validation GUI. It is useful to add
        centroids for crossing blobs that are missing some centroids, or to
        individual blobs that should have been classified as crossings and
        are also missing some centroids.

        Parameters
        ----------
        centroid : tuple
            Centroid to be added to the blob
        identity : int
            Identity of the centroid to be added
        """

        self.init_validator_variables()

        self.user_generated_centroids.append(centroid)
        self.user_generated_identities.append(identity)

    def update_identity(
        self,
        old_identity: int | None,
        new_identity: int | None,
        close_to_centroid: tuple[float, float],
    ):
        """[Validation] Updates the identity of the blob.

        This method is used during the validation GUI.
        It populates the private attributes `user_generated_identities`
        and `user_generated_centroids`.

        Parameters
        ----------
        new_identity : int
            new value for the identity of the blob
        old_identity : int
            old value of the identity of the blob. It must be specified when the
            blob has multiple identities already assigned.
        centroid : tuple
            centroid which identity must be updated.
        """
        self.init_validator_variables()

        index, centroid, dist = self.index_and_centroid_closer_to(
            close_to_centroid, old_identity
        )

        self.user_generated_identities[index] = new_identity
        self.user_generated_centroids[index] = centroid
        return centroid

    def propagate_identity(
        self,
        old_identity: int | None,
        new_identity: int | None,
        centroid: tuple[float, float],
    ):
        """[Validation] Propagates the new identity to next and previous blobs.

        This method called in the validation GUI when the used updates the
        identity of a given blob.

        Returns the frame range where identity changes occurred

        Parameters
        ----------
        old_identity : int
            Previous identity of the blob
        new_identity : int
            New identity of the blob
        centroid : tuple
            [description]
        """

        blobs_stack: list[tuple[Blob, tuple[float, float]]]
        first_frame_modified = self.frame_number
        last_frame_modified = self.frame_number

        blobs_stack = [(next_blob, centroid) for next_blob in self.next]
        while blobs_stack:
            current, previous_centroid = blobs_stack.pop()
            if current.fragment_identifier != self.fragment_identifier:
                continue
            try:
                new_centroid = current.update_identity(
                    old_identity, new_identity, previous_centroid
                )
            except ValueError:  # centroid not found on "current"
                continue
            blobs_stack += [(next_blob, new_centroid) for next_blob in current.next]
            last_frame_modified = current.frame_number

        blobs_stack += [(prev_blob, centroid) for prev_blob in self.previous]
        while blobs_stack:
            current, previous_centroid = blobs_stack.pop()
            if current.fragment_identifier != self.fragment_identifier:
                continue

            try:
                new_centroid = current.update_identity(
                    old_identity, new_identity, previous_centroid
                )
            except ValueError:  # centroid not found on "current"
                continue
            blobs_stack += [(prev_blob, new_centroid) for prev_blob in current.previous]
            first_frame_modified = current.frame_number

        return first_frame_modified, last_frame_modified

    @property
    def properties(self) -> Sequence[str]:
        return (
            (
                (("Individual" if self.is_an_individual else "Crossing") + " Blob")
                + (" (forced)" if self.forced_crossing else "")
            ),
            f"{self.contour.shape[0]} vertices in contour of {self.area:.0f} px area",
            f"In fragment {self.fragment_identifier}",
            f"Linked to {self.n_previous} previous blobs",
            f"Linked to {self.n_next} next blobs",
            ("Used" if self.used_for_training_crossings else "Not used")
            + " for training crossings",
            f"Seems like individual: {self.seems_like_individual}",
            f"Pixels are from eroded blob: {self.pixels_are_from_eroded_blob}",
            f"Predicted identity: {self.identity}",
            f"Corrected solving jumps {self.identity_corrected_solving_jumps}",
            f"Corrected solving gaps: {self.identities_corrected_closing_gaps}",
            f"assigned identities: {self.assigned_identities}",
            f"assigned centroids: {repr_of_list_of_points(self.assigned_centroids)}",
            f"user identities: {self.user_generated_identities}",
            f"user centroids: {repr_of_list_of_points(self.user_generated_centroids)}",
            f"final identities: {list(self.final_identities)}",
            f"final centroids: {repr_of_list_of_points(self.final_centroids)}",
        )


def repr_of_list_of_points(list_of_points) -> str:
    if list_of_points is None:
        return "None"
    list_of_str = [
        f"({point[0]:.1f}, {point[1]:.1f})" if point is not None else "None"
        for point in list_of_points
    ]
    return f"[{', '.join(list_of_str)}]"
