Repository: napari/napari

Issue:
Track Layer can not start from non zero timepoint
## 🐛 Bug
As noted in https://forum.image.sc/t/napari-track-layer-astonishing-error/45828 the tracks layer of napari has an unusual bug which requires the tracklets to start form time 0, hence if there is no cell at time point 0 the track layer would throw an unhandled exception exception.
This minimal example will reproduce the error:

## To Reproduce
```
import napari

tracks_data = [
     [1,1,41,768],
     [1,2,34,758],
     [2,3,36,761],
     [2,4,29,764]]
with napari.gui_qt():
    Tracks = napari.view_tracks(tracks_data,name='tracks')
```
## Error thrown

```
ERROR:root:Unhandled exception:
Traceback (most recent call last):
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/_qt/event_loop.py", line 79, in gui_qt
    yield app
  File "<ipython-input-2-498e4844dd1f>", line 9, in <module>
    Tracks = napari.view_tracks(tracks_data,name='tracks')
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/view_layers.py", line 9, in view_tracks
    (where <layer_type> is replaced with one of the layer types):
  File "<string>", line 3, in add_tracks
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/components/add_layers_mixin.py", line 72, in add_layer
    self.layers.append(layer)
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/utils/list/_model.py", line 59, in append
    self.events.added(item=obj, index=len(self) - 1)
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/utils/events/event.py", line 514, in __call__
    self._invoke_callback(cb, event)
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/utils/events/event.py", line 531, in _invoke_callback
    _handle_exception(
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/utils/events/event.py", line 529, in _invoke_callback
    cb(event)
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/_qt/qt_viewer.py", line 323, in _add_layer
    vispy_layer = create_vispy_visual(layer)
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/_vispy/utils.py", line 44, in create_vispy_visual
    return visual(layer)
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/_vispy/vispy_tracks_layer.py", line 50, in __init__
    self._on_data_change()
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/_vispy/vispy_tracks_layer.py", line 62, in _on_data_change
    labels_text, labels_pos = self.layer.track_labels
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/layers/tracks/tracks.py", line 543, in track_labels
    padded_positions = self._pad_display_data(positions)
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/napari/layers/tracks/tracks.py", line 306, in _pad_display_data
    data = np.pad(data, ((0, 0), (0, 1)), 'constant')
  File "<__array_function__ internals>", line 5, in pad
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/numpy/lib/arraypad.py", line 748, in pad
    pad_width = _as_pairs(pad_width, array.ndim, as_index=True)
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/numpy/lib/arraypad.py", line 523, in _as_pairs
    return np.broadcast_to(x, (ndim, 2)).tolist()
  File "<__array_function__ internals>", line 5, in broadcast_to
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/numpy/lib/stride_tricks.py", line 182, in broadcast_to
    return _broadcast_to(array, shape, subok=subok, readonly=True)
  File "/home/kapoorlab/anaconda3/envs/tensorflowGPU/lib/python3.8/site-packages/numpy/lib/stride_tricks.py", line 125, in _broadcast_to
    it = np.nditer(
ValueError: operands could not be broadcast together with remapped shapes [original->remapped]: (2,2) and requested shape (3,2)
```
## Expected behavior
Let the tracklets start from non zero timepoints

## Environment
napari: 0.4.0
Platform: Linux-5.9.1-2-default-x86_64-with-glibc2.10
Python: 3.8.5 (default, Sep  4 2020, 07:30:14)  [GCC 7.3.0]
Qt: 5.12.10
PyQt5: 5.12.3
NumPy: 1.18.5
SciPy: 1.4.1
Dask: 2.30.0
VisPy: 0.6.5

GL version:  3.0 Mesa 20.2.2
MAX_TEXTURE_SIZE: 8192

Plugins:
  - napari_plugin_engine: 0.1.8
  - svg: 0.1.4




Discussion:
Thank you for raising, @kapoorlab!

> As noted in forum.image.sc/t/napari-track-layer-astonishing-error/45828

That URL is hilarious 😂 

Resolving patch:
diff --git a/napari/layers/tracks/_track_utils.py b/napari/layers/tracks/_track_utils.py
--- a/napari/layers/tracks/_track_utils.py
+++ b/napari/layers/tracks/_track_utils.py
@@ -124,7 +124,7 @@ def data(self, data: Union[list, np.ndarray]):
         # will be an integer - however, the time index does not necessarily
         # need to be an int, and the shader will render correctly.
         frames = list(set(self._points[:, 0].astype(np.uint).tolist()))
-        self._points_lookup = [None] * (max(frames) + 1)
+        self._points_lookup = {}
         for f in frames:
             idx = np.where(self._points[:, 0] == f)[0]
             self._points_lookup[f] = slice(min(idx), max(idx) + 1, 1)
@@ -385,6 +385,9 @@ def graph_times(self) -> np.ndarray:
     def track_labels(self, current_time: int) -> tuple:
         """ return track labels at the current time """
         # this is the slice into the time ordered points array
+        if current_time not in self._points_lookup:
+            return [], []
+
         lookup = self._points_lookup[current_time]
         pos = self._points[lookup, ...]
         lbl = [f'ID:{i}' for i in self._points_id[lookup]]
diff --git a/napari/layers/tracks/tracks.py b/napari/layers/tracks/tracks.py
--- a/napari/layers/tracks/tracks.py
+++ b/napari/layers/tracks/tracks.py
@@ -533,12 +533,11 @@ def graph_times(self) -> np.ndarray:
     @property
     def track_labels(self) -> tuple:
         """ return track labels at the current time """
+        labels, positions = self._manager.track_labels(self.current_time)
 
-        # check that current time is still within the frame map
-        if self.current_time < 0 or self.current_time > self._manager.max_time:
-            # need to return a tuple for pos to clear the vispy text visual
+        # if there are no labels, return empty for vispy
+        if not labels:
             return None, (None, None)
 
-        labels, positions = self._manager.track_labels(self.current_time)
         padded_positions = self._pad_display_data(positions)
         return labels, padded_positions


Test patch:
diff --git a/napari/layers/tracks/_tests/test_tracks.py b/napari/layers/tracks/_tests/test_tracks.py
--- a/napari/layers/tracks/_tests/test_tracks.py
+++ b/napari/layers/tracks/_tests/test_tracks.py
@@ -57,6 +57,17 @@ def test_track_layer_data():
     assert np.all(layer.data == data)
 
 
+@pytest.mark.parametrize(
+    "timestamps", [np.arange(100, 200), np.arange(100, 300, 2)]
+)
+def test_track_layer_data_nonzero_starting_time(timestamps):
+    """Test data with sparse timestamps or not starting at zero."""
+    data = np.zeros((100, 4))
+    data[:, 1] = timestamps
+    layer = Tracks(data)
+    assert np.all(layer.data == data)
+
+
 def test_track_layer_data_flipped():
     """Test data flipped."""
     data = np.zeros((100, 4))