Skip to content

color

sleap.gui.color

Logic for determining what color/width to draw instance nodes/edges.

The color can be determined by the current color palette as well as settings on the ColorManager object:

  • distinctly_color: "instances", "nodes", or "edges"
  • color_predicted: whether to use colors for predicted instances, or just plot them in yellow/grey

Initial color palette (and other settings, like default line width) is read from user preferences but can be changed after object is created.

Classes:

Name Description
ColorManager

Class to determine color to use for track.

ColorManager

Class to determine color to use for track.

The color depends on the order of the tracks in Labels object, so we need to initialize with Labels.

Parameters:

Name Type Description Default
labels Labels

The :class:Labels dataset which contains the tracks for which we want colors.

None
palette str

String with the color palette name to use.

'standard'

Methods:

Name Description
color_to_tuple

Convert and ensure color is (r, g, b)-tuple.

fix_index

Returns an index within range of color palette.

get_color_by_idx

Returns color tuple corresponding to item index.

get_item_color

Gets (r, g, b) tuple of color to use for drawing item.

get_item_pen_width

Gets width of pen to use for drawing item.

get_item_type_pen_width

Gets pen width to use for given item type (as string).

get_pseudo_track_index

Returns an index for giving track colors to instances without track.

get_track_color

Returns the color to use for a given track.

is_edge

Returns whether item is an edge, i.e., pair of nodes.

is_node

Returns whether item is a node, i.e., Node or node name.

is_predicted

Returns whether instance is predicted.

is_sequence

Returns whether item is a tuple or list.

set_palette

Functional alias for palette property setter.

Attributes:

Name Type Description
labels

Gets or sets labels dataset for which we are coloring tracks.

palette

Gets or sets palette (by name).

palette_names Iterable[Text]

Gets list of palette names.

tracks Iterable[Track]

Gets tracks for project.

Source code in sleap/gui/color.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
class ColorManager:
    """Class to determine color to use for track.

    The color depends on the order of the tracks in `Labels` object,
    so we need to initialize with `Labels`.

    Args:
        labels: The :class:`Labels` dataset which contains the tracks for
            which we want colors.
        palette: String with the color palette name to use.
    """

    # Upper bound on colors generated to extend a palette. Past roughly this
    # many, no set of colors is reliably distinguishable, so we stop and cycle.
    max_generated_colors = 128

    # Candidate colors to pick from; identical for every instance, so built once.
    _candidate_cache = None

    def __init__(self, labels: Labels = None, palette: str = "standard"):
        self.labels = labels

        with open(get_config_file("colors.yaml"), "r") as f:
            self._palettes = yaml.load(f, Loader=yaml.SafeLoader)

        self._color_map = []
        self._generated_color_map = None
        self._generated_nearest = None
        self.distinctly_color = "instances"
        self.color_predicted = True

        self.index_mode = "cycle"
        self._index_mode_functions = dict(
            cycle=lambda i, c: i % c, clip=lambda i, c: min(i, c - 1)
        )

        self.set_palette(palette)

        self.uncolored_prediction_color = (250, 250, 10)

        if prefs["bold lines"]:
            self.thick_pen_width = 6
        else:
            self.thick_pen_width = 3

        self.medium_pen_width = self.thick_pen_width // 2
        self.default_pen_width = max(1, self.thick_pen_width // 4)

    @property
    def labels(self):
        """Gets or sets labels dataset for which we are coloring tracks."""
        return self._labels

    @labels.setter
    def labels(self, val):
        self._labels = val

    @property
    def palette(self):
        """Gets or sets palette (by name)."""
        return self._palette

    @palette.setter
    def palette(self, palette: Union[Text, Iterable[ColorTupleStringType]]):
        self._palette = palette
        self._generated_color_map = None
        self._generated_nearest = None

        if isinstance(palette, Text):
            self.index_mode = "clip" if palette.endswith("+") else "cycle"

            if palette in self._palettes:
                self._color_map = self._palettes[palette]
            else:
                # Can't find palette by name so just use standard palette.
                self._color_map = self._palettes["standard"]
        else:
            # If palette is not given by name, it should be list of
            # "r,g,b" strings.
            self._color_map = palette

    @property
    def palette_names(self) -> Iterable[Text]:
        """Gets list of palette names."""
        return self._palettes.keys()

    @property
    def tracks(self) -> Iterable[Track]:
        """Gets tracks for project."""
        if self.labels:
            return self.labels.tracks
        return []

    def set_palette(self, palette: Union[Text, Iterable[ColorTupleStringType]]):
        """Functional alias for palette property setter."""
        self.palette = palette

    def fix_index(self, idx: int) -> int:
        """Returns an index within range of color palette."""
        color_map = self._color_map_for_idx(idx)
        return self._index_mode_functions[self.index_mode](idx, len(color_map))

    @staticmethod
    def _to_lab(colors):
        """Converts (r, g, b) colors to CIELAB, where distances match perception."""
        import numpy as np
        from skimage.color import rgb2lab

        rgb = np.asarray(colors, dtype=float).reshape(-1, 1, 3) / 255.0
        return rgb2lab(rgb).reshape(-1, 3)

    @classmethod
    def _color_candidates(cls):
        """Returns the colors we are allowed to pick from, and their CIELAB values.

        This is a coarse sample of every color the screen can show, keeping only
        those with mid lightness and enough saturation to stay visible over both
        dark and bright video. It never changes, so it is built once and shared.
        """
        if cls._candidate_cache is None:
            import numpy as np

            step = 15
            grid = np.array(
                [
                    (r, g, b)
                    for r in range(0, 256, step)
                    for g in range(0, 256, step)
                    for b in range(0, 256, step)
                ],
                dtype=float,
            )
            grid_lab = cls._to_lab(grid)
            chroma = np.hypot(grid_lab[:, 1], grid_lab[:, 2])
            usable = (grid_lab[:, 0] >= 45) & (grid_lab[:, 0] <= 85) & (chroma >= 38)
            cls._candidate_cache = (grid[usable], grid_lab[usable])

        return cls._candidate_cache

    def _grow_color_map(self, n_colors: int) -> Iterable[ColorTupleType]:
        """Extends the palette so that more items than colors still look distinct.

        The palette is kept as the first colors, so items already on screen never
        change color; the extra colors are picked one at a time, each as far as
        possible in perceptual (CIEDE2000) color space from every color already
        picked. Candidates are limited to mid lightness and reasonable saturation
        so they stay visible over both dark and bright video.

        Args:
            n_colors: Total number of colors needed.

        Returns:
            List of (r, g, b)-tuples of length `n_colors`.
        """
        import numpy as np
        from skimage.color import deltaE_ciede2000

        grid, grid_lab = self._color_candidates()

        if self._generated_color_map is not None:
            # Picking is greedy, so the colors already generated are exactly the
            # ones we would pick again; carry on from where we left off instead
            # of starting over.
            colors = list(self._generated_color_map)
            nearest = self._generated_nearest.copy()
        else:
            colors = [self.color_to_tuple(c) for c in self._color_map]
            # Distance from every candidate to the nearest color already chosen.
            nearest = np.full(len(grid), np.inf)
            for lab in self._to_lab(colors):
                nearest = np.minimum(
                    nearest,
                    deltaE_ciede2000(np.tile(lab, (len(grid_lab), 1)), grid_lab),
                )

        while len(colors) < n_colors:
            pick = int(np.argmax(nearest))
            colors.append(tuple(int(v) for v in grid[pick]))
            nearest = np.minimum(
                nearest,
                deltaE_ciede2000(np.tile(grid_lab[pick], (len(grid_lab), 1)), grid_lab),
            )

        self._generated_nearest = nearest
        return colors

    def _color_map_for_idx(self, idx: int) -> Iterable[ColorTupleType]:
        """Returns a color map long enough to have a distinct color for `idx`."""
        if idx < len(self._color_map) or self.index_mode != "cycle":
            return self._color_map

        # Past the cap the colors are too close together to tell apart anyway,
        # so stop growing and let the generated map cycle.
        # Grow in blocks so that adding one track doesn't rebuild the map.
        n_needed = min(((idx // 32) + 1) * 32, self.max_generated_colors)

        if self._generated_color_map is None or (
            len(self._generated_color_map) < n_needed
        ):
            try:
                self._generated_color_map = self._grow_color_map(n_needed)
            except Exception:
                # Never let coloring break drawing; fall back to cycling.
                return self._color_map

        return self._generated_color_map

    def get_color_by_idx(self, idx: int) -> ColorTupleType:
        """Returns color tuple corresponding to item index."""
        color_map = self._color_map_for_idx(idx)
        return self.color_to_tuple(color_map[self.fix_index(idx)])

    @staticmethod
    def color_to_tuple(color: Union[Text, Iterable[int]]) -> ColorTupleType:
        """Convert and ensure color is (r, g, b)-tuple."""
        if isinstance(color, Text):
            split_string = color.split(",")
            if len(split_string) != 3:
                raise ValueError(f"Color '{color}' is not 'r,g,b' string.")
            try:
                result = tuple(map(int, split_string))
                return result
            except Exception:
                raise ValueError(f"Color '{color}' is not 'r,g,b' string.")

        if len(color) != 3:
            raise ValueError(f"Color '{color}' is not (r,g,b) tuple.")

        try:
            result = tuple(map(int, color))
            return result
        except Exception:
            raise ValueError(f"Color '{color}' is not (r,g,b) tuple.")

    def get_pseudo_track_index(
        self, instance: "Instance", frame=None
    ) -> Union[Track, int]:
        """
        Returns an index for giving track colors to instances without track.
        """
        if instance.track:
            return instance.track
        if not frame:
            return 0

        untracked_user_instances = [
            inst for inst in frame.user_instances if inst.track is None
        ]
        untracked_predicted_instances = [
            inst for inst in frame.predicted_instances if inst.track is None
        ]

        return len(self.tracks) + (
            untracked_user_instances + untracked_predicted_instances
        ).index(instance)

    def get_track_color(self, track: Union[Track, int]) -> ColorTupleType:
        """Returns the color to use for a given track.

        Args:
            track: `Track` object or an int
        Returns:
            (r, g, b)-tuple
        """
        track_idx = track
        if isinstance(track, Track):
            track_idx = self.tracks.index(track) if track in self.tracks else None
        if track_idx is None:
            return (0, 0, 0)

        return self.get_color_by_idx(track_idx)

    @classmethod
    def is_sequence(cls, item) -> bool:
        """Returns whether item is a tuple or list."""
        return isinstance(item, tuple) or isinstance(item, list)

    @classmethod
    def is_edge(cls, item) -> bool:
        """Returns whether item is an edge, i.e., pair of nodes."""
        return (
            cls.is_sequence(item)
            and len(item) == 2
            and cls.is_node(item[0])
            or isinstance(item, Edge)
        )

    @staticmethod
    def is_node(item) -> bool:
        """Returns whether item is a node, i.e., Node or node name."""
        return isinstance(item, Node) or isinstance(item, str)

    @staticmethod
    def is_predicted(instance) -> bool:
        """Returns whether instance is predicted."""
        return hasattr(instance, "score")

    def get_item_pen_width(
        self, item: Any, parent_instance: Optional[Instance] = None
    ) -> float:
        """Gets width of pen to use for drawing item."""

        if self.is_node(item):
            if self.distinctly_color == "nodes":
                return self.thick_pen_width

            if self.is_predicted(parent_instance):
                is_first_node = item == parent_instance.skeleton.nodes[0]
                return self.thick_pen_width if is_first_node else self.medium_pen_width
            else:
                return self.medium_pen_width

        if self.is_edge(item):
            if self.distinctly_color == "edges":
                return self.thick_pen_width

        return self.default_pen_width

    def get_item_type_pen_width(self, item_type: str) -> float:
        """Gets pen width to use for given item type (as string)."""
        if item_type == "node":
            if self.distinctly_color == "nodes":
                return self.thick_pen_width
            return self.medium_pen_width

        if item_type == "edge":
            if self.distinctly_color == "edges":
                return self.thick_pen_width

        return self.default_pen_width

    def get_item_color(
        self,
        item: Any,
        parent_instance: Optional[Instance] = None,
        parent_skeleton: Optional[Skeleton] = None,
        frame: Optional[LabeledFrame] = None,
    ) -> ColorTupleType:
        """Gets (r, g, b) tuple of color to use for drawing item."""

        if not parent_instance and isinstance(item, Instance):
            parent_instance = item

        if not parent_skeleton and hasattr(parent_instance, "skeleton"):
            parent_skeleton = parent_instance.skeleton

        is_predicted = False
        if parent_instance and self.is_predicted(parent_instance):
            is_predicted = True

        if is_predicted and not self.color_predicted:
            if isinstance(item, Node):
                return self.uncolored_prediction_color

            return (128, 128, 128)

        if self.distinctly_color == "instances" or hasattr(item, "track"):
            track = None
            if hasattr(item, "track"):
                track = item.track
            elif parent_instance:
                track = parent_instance.track

            if track is None and parent_instance:
                # Get an index for items without track
                track = self.get_pseudo_track_index(parent_instance, frame=frame)

            return self.get_track_color(track=track)

        if self.distinctly_color == "nodes" and parent_skeleton:
            node = None
            if isinstance(item, Node):
                node = item
            elif self.is_edge(item):
                # use dst node for coloring edge
                node = item[1]

            if node:
                node_idx = node_to_index(parent_skeleton, node)
                return self.get_color_by_idx(node_idx)

            # return (255, 0, 0)

        if self.distinctly_color == "edges" and parent_skeleton:
            edge_idx = 0
            if self.is_edge(item):
                edge_idx = edge_to_index(parent_skeleton, *item)
            elif self.is_node(item):
                for i, (src, dst) in enumerate(parent_skeleton.edges):
                    if dst == item:
                        edge_idx = i
                        break

            return self.get_color_by_idx(edge_idx)

        return (0, 0, 0)

labels property writable

Gets or sets labels dataset for which we are coloring tracks.

palette property writable

Gets or sets palette (by name).

palette_names property

Gets list of palette names.

tracks property

Gets tracks for project.

color_to_tuple(color) staticmethod

Convert and ensure color is (r, g, b)-tuple.

Source code in sleap/gui/color.py
@staticmethod
def color_to_tuple(color: Union[Text, Iterable[int]]) -> ColorTupleType:
    """Convert and ensure color is (r, g, b)-tuple."""
    if isinstance(color, Text):
        split_string = color.split(",")
        if len(split_string) != 3:
            raise ValueError(f"Color '{color}' is not 'r,g,b' string.")
        try:
            result = tuple(map(int, split_string))
            return result
        except Exception:
            raise ValueError(f"Color '{color}' is not 'r,g,b' string.")

    if len(color) != 3:
        raise ValueError(f"Color '{color}' is not (r,g,b) tuple.")

    try:
        result = tuple(map(int, color))
        return result
    except Exception:
        raise ValueError(f"Color '{color}' is not (r,g,b) tuple.")

fix_index(idx)

Returns an index within range of color palette.

Source code in sleap/gui/color.py
def fix_index(self, idx: int) -> int:
    """Returns an index within range of color palette."""
    color_map = self._color_map_for_idx(idx)
    return self._index_mode_functions[self.index_mode](idx, len(color_map))

get_color_by_idx(idx)

Returns color tuple corresponding to item index.

Source code in sleap/gui/color.py
def get_color_by_idx(self, idx: int) -> ColorTupleType:
    """Returns color tuple corresponding to item index."""
    color_map = self._color_map_for_idx(idx)
    return self.color_to_tuple(color_map[self.fix_index(idx)])

get_item_color(item, parent_instance=None, parent_skeleton=None, frame=None)

Gets (r, g, b) tuple of color to use for drawing item.

Source code in sleap/gui/color.py
def get_item_color(
    self,
    item: Any,
    parent_instance: Optional[Instance] = None,
    parent_skeleton: Optional[Skeleton] = None,
    frame: Optional[LabeledFrame] = None,
) -> ColorTupleType:
    """Gets (r, g, b) tuple of color to use for drawing item."""

    if not parent_instance and isinstance(item, Instance):
        parent_instance = item

    if not parent_skeleton and hasattr(parent_instance, "skeleton"):
        parent_skeleton = parent_instance.skeleton

    is_predicted = False
    if parent_instance and self.is_predicted(parent_instance):
        is_predicted = True

    if is_predicted and not self.color_predicted:
        if isinstance(item, Node):
            return self.uncolored_prediction_color

        return (128, 128, 128)

    if self.distinctly_color == "instances" or hasattr(item, "track"):
        track = None
        if hasattr(item, "track"):
            track = item.track
        elif parent_instance:
            track = parent_instance.track

        if track is None and parent_instance:
            # Get an index for items without track
            track = self.get_pseudo_track_index(parent_instance, frame=frame)

        return self.get_track_color(track=track)

    if self.distinctly_color == "nodes" and parent_skeleton:
        node = None
        if isinstance(item, Node):
            node = item
        elif self.is_edge(item):
            # use dst node for coloring edge
            node = item[1]

        if node:
            node_idx = node_to_index(parent_skeleton, node)
            return self.get_color_by_idx(node_idx)

        # return (255, 0, 0)

    if self.distinctly_color == "edges" and parent_skeleton:
        edge_idx = 0
        if self.is_edge(item):
            edge_idx = edge_to_index(parent_skeleton, *item)
        elif self.is_node(item):
            for i, (src, dst) in enumerate(parent_skeleton.edges):
                if dst == item:
                    edge_idx = i
                    break

        return self.get_color_by_idx(edge_idx)

    return (0, 0, 0)

get_item_pen_width(item, parent_instance=None)

Gets width of pen to use for drawing item.

Source code in sleap/gui/color.py
def get_item_pen_width(
    self, item: Any, parent_instance: Optional[Instance] = None
) -> float:
    """Gets width of pen to use for drawing item."""

    if self.is_node(item):
        if self.distinctly_color == "nodes":
            return self.thick_pen_width

        if self.is_predicted(parent_instance):
            is_first_node = item == parent_instance.skeleton.nodes[0]
            return self.thick_pen_width if is_first_node else self.medium_pen_width
        else:
            return self.medium_pen_width

    if self.is_edge(item):
        if self.distinctly_color == "edges":
            return self.thick_pen_width

    return self.default_pen_width

get_item_type_pen_width(item_type)

Gets pen width to use for given item type (as string).

Source code in sleap/gui/color.py
def get_item_type_pen_width(self, item_type: str) -> float:
    """Gets pen width to use for given item type (as string)."""
    if item_type == "node":
        if self.distinctly_color == "nodes":
            return self.thick_pen_width
        return self.medium_pen_width

    if item_type == "edge":
        if self.distinctly_color == "edges":
            return self.thick_pen_width

    return self.default_pen_width

get_pseudo_track_index(instance, frame=None)

Returns an index for giving track colors to instances without track.

Source code in sleap/gui/color.py
def get_pseudo_track_index(
    self, instance: "Instance", frame=None
) -> Union[Track, int]:
    """
    Returns an index for giving track colors to instances without track.
    """
    if instance.track:
        return instance.track
    if not frame:
        return 0

    untracked_user_instances = [
        inst for inst in frame.user_instances if inst.track is None
    ]
    untracked_predicted_instances = [
        inst for inst in frame.predicted_instances if inst.track is None
    ]

    return len(self.tracks) + (
        untracked_user_instances + untracked_predicted_instances
    ).index(instance)

get_track_color(track)

Returns the color to use for a given track.

Parameters:

Name Type Description Default
track Union[Track, int]

Track object or an int

required
Source code in sleap/gui/color.py
def get_track_color(self, track: Union[Track, int]) -> ColorTupleType:
    """Returns the color to use for a given track.

    Args:
        track: `Track` object or an int
    Returns:
        (r, g, b)-tuple
    """
    track_idx = track
    if isinstance(track, Track):
        track_idx = self.tracks.index(track) if track in self.tracks else None
    if track_idx is None:
        return (0, 0, 0)

    return self.get_color_by_idx(track_idx)

is_edge(item) classmethod

Returns whether item is an edge, i.e., pair of nodes.

Source code in sleap/gui/color.py
@classmethod
def is_edge(cls, item) -> bool:
    """Returns whether item is an edge, i.e., pair of nodes."""
    return (
        cls.is_sequence(item)
        and len(item) == 2
        and cls.is_node(item[0])
        or isinstance(item, Edge)
    )

is_node(item) staticmethod

Returns whether item is a node, i.e., Node or node name.

Source code in sleap/gui/color.py
@staticmethod
def is_node(item) -> bool:
    """Returns whether item is a node, i.e., Node or node name."""
    return isinstance(item, Node) or isinstance(item, str)

is_predicted(instance) staticmethod

Returns whether instance is predicted.

Source code in sleap/gui/color.py
@staticmethod
def is_predicted(instance) -> bool:
    """Returns whether instance is predicted."""
    return hasattr(instance, "score")

is_sequence(item) classmethod

Returns whether item is a tuple or list.

Source code in sleap/gui/color.py
@classmethod
def is_sequence(cls, item) -> bool:
    """Returns whether item is a tuple or list."""
    return isinstance(item, tuple) or isinstance(item, list)

set_palette(palette)

Functional alias for palette property setter.

Source code in sleap/gui/color.py
def set_palette(self, palette: Union[Text, Iterable[ColorTupleStringType]]):
    """Functional alias for palette property setter."""
    self.palette = palette