Skip to content

Commit

Permalink
Merge pull request #109 from funkelab/import_external_tracks_menu
Browse files Browse the repository at this point in the history
implement a dialog for importing externally generated tracks from csv
  • Loading branch information
cmalinmayor authored Feb 19, 2025
2 parents 2c5dac2 + b3f7593 commit 85b6244
Show file tree
Hide file tree
Showing 18 changed files with 1,427 additions and 70 deletions.
23 changes: 15 additions & 8 deletions docs/source/tree_view.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,28 @@ Please visit :doc:`key bindings <key_bindings>` page for a complete list of avai
Viewing Externally Generated Tracks
***********************************
It is also possible to view tracks that were not created from the motile widget using
the synchronized Tree View and napari layers. This is not accessible from the UI, so
you will need to make a python script to create a Tracks object and load it into the
viewer.
the synchronized Tree View and napari layers. To do so, navigate to the ``Results List`` tab and select ``External tracks from CSV`` in the dropdown menu at the bottom of the widgets, and click ``Load``.
A pop up menu will allow you to select a CSV file and map its columns to the required default attributes and optional additional attributes. You may also provide the accompanying segmentation and specify scaling information.

A `SolutionTracks object`_ contains a networkx graph representing the tracking result, and optionally
The following columns have to be selected:

- time: representing the position of the object in the time dimension.
- x: x centroid coordinate of the object.
- y: y centroid coordinate of the object.
- z (optional): z centroid coordinate of the object, if it is a 3D object.
- id: unique id of the object.
- parent_id: id of the directly connected predecessor (parent) of the object. Should be empty if the object is at the start of a lineage.
- seg_id: label value in the segmentation image data (if provided) that corresponds to the object id.

From this, a `SolutionTracks object`_ is generated, containing a networkx graph representing the tracking result, and optionally
a segmentation. The networkx graph is directed, with nodes representing detections and
edges going from a detection in time t to the same object in t+n (edges go forward in time).
Nodes must have an attribute representing time, by default named "time" but a different name
can be stored in the ``Tracks.time_attr`` attribute. Nodes must also have one or more attributes
representing position. The default way of storing positions on nodes is an attribute called
"pos" containing a list of position values, but dimensions can also be stored in separate attributes
(e.g. "x" and "y", each with one value). The name or list of names of the position attributes
should be specified in ``Tracks.pos_attr``. If you want to view tracks by area of the nodes,
you will also need to store the area of the corresponding segmentation on the nodes of the graph
in an ``area`` attribute.
should be specified in ``Tracks.pos_attr``. If a segmentation is provided but no ``area`` attribute, it will be computed automatically.

The segmentation is expected to be a numpy array with time as the first dimension, followed
by the position dimensions in the same order as the ``Tracks.pos_attr``. The segmentation
Expand All @@ -43,7 +50,7 @@ motile_toolbox called ensure_unique_labels that relabels a segmentation to be un
across time if needed. If a segmentation is provided, the node ids in the graph should
match label id of the corresponding segmentation.

An example script that loads a tracks object from a CSV and segmentation array is provided in `scripts/view_external_tracks.csv`. Once you have a Tracks object in the format described above,
An example script that loads a tracks object from a CSV and segmentation array is provided in `scripts/view_external_tracks.py`. Once you have a Tracks object in the format described above,
the following lines will view it in the Tree View and create synchronized napari layers
(Points, Labels, and Tracks) to visualize the provided tracks.::

Expand Down
27 changes: 24 additions & 3 deletions scripts/view_external_tracks.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
import napari
import pandas as pd

from motile_tracker.application_menus import MainApp
from motile_tracker.data_views.views_coordinator.tracks_viewer import TracksViewer
from motile_tracker.example_data import Fluo_N2DL_HeLa
from motile_tracker.utils.load_tracks import tracks_from_csv
from motile_tracker.import_export.load_tracks import tracks_from_df

if __name__ == "__main__":
# load the example data
raw_layer_info, labels_layer_info, points_layer_info = Fluo_N2DL_HeLa()
segmentation_arr = labels_layer_info[0]
# the segmentation ids in this file correspond to the segmentation ids in the
# example segmentation data, loaded above
csvfile = "hela_example_tracks.csv"
tracks = tracks_from_csv(csvfile, segmentation_arr)
csvfile = "scripts/hela_example_tracks.csv"
selected_columns = {
"time": "t",
"y": "y",
"x": "x",
"id": "id",
"parent_id": "parent_id",
"seg_id": "id",
}

df = pd.read_csv(csvfile)

# Create new columns for each feature based on the original column values
for feature, column in selected_columns.items():
df[feature] = df[column]

tracks = tracks_from_df(
df=df,
segmentation=segmentation_arr,
scale=[1, 1, 1],
)

viewer = napari.Viewer()
raw_data, raw_kwargs, _ = raw_layer_info
Expand Down
20 changes: 20 additions & 0 deletions src/motile_tracker/data_model/solution_tracks.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ def export_tracks(self, outfile: Path | str):
header = ["t", "z", "y", "x", "id", "parent_id", "track_id"]
if self.ndim == 3:
header = [header[0]] + header[2:] # remove z

# Add the extra attributes that are not part of the default ones
additional_attrs = {
k
for n in self.graph.nodes
for k in self.graph.nodes[n]
if k
not in (
NodeAttr.TIME.value,
NodeAttr.SEG_ID.value,
NodeAttr.TRACK_ID.value,
NodeAttr.POS.value,
)
}
header = header + list(additional_attrs)

with open(outfile, "w") as f:
f.write(",".join(header))
for node_id in self.graph.nodes():
Expand All @@ -135,12 +151,16 @@ def export_tracks(self, outfile: Path | str):
track_id = self.get_track_id(node_id)
time = self.get_time(node_id)
position = self.get_position(node_id)
attrs = [
self._get_node_attr(node_id, attr) for attr in additional_attrs
]
row = [
time,
*position,
node_id,
parent_id,
track_id,
*attrs,
]
f.write("\n")
f.write(",".join(map(str, row)))
Expand Down
2 changes: 1 addition & 1 deletion src/motile_tracker/data_model/tracks.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ def _compute_ndim(
ndim = ndims[0]
if not all(d == ndim for d in ndims):
raise ValueError(
f"Dimensions from segmentation {seg_ndim}, scale {scale_ndim}, and ndim {provided_ndim} must match"
f"Dimensions from segmentation: {seg_ndim}, scale: {scale_ndim}, and ndim: {provided_ndim} must match"
)
return ndim

Expand Down
5 changes: 5 additions & 0 deletions src/motile_tracker/data_views/views/layers/track_points.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ def click(layer, event):
# listen to updates of the data
self.events.data.connect(self._update_data)

# connect to changing the point size in the UI
self.events.current_size.connect(
lambda: self.set_point_size(size=self.current_size)
)

# listen to updates in the selected data (from the point selection tool)
# to update the nodes in self.tracks_viewer.selected_nodes
self.selected_data.events.items_changed.connect(self._update_selection)
Expand Down
38 changes: 34 additions & 4 deletions src/motile_tracker/data_views/views_coordinator/tracks_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from napari._qt.qt_resources import QColoredSVGIcon
from qtpy.QtCore import Signal
from qtpy.QtWidgets import (
QComboBox,
QDialog,
QFileDialog,
QGroupBox,
QHBoxLayout,
Expand All @@ -19,7 +21,10 @@
)
from superqt.fonticon import icon as qticon

from motile_tracker.data_model import Tracks
from motile_tracker.data_model import SolutionTracks, Tracks
from motile_tracker.import_export.menus.import_external_tracks_dialog import (
ImportTracksDialog,
)
from motile_tracker.motile.backend.motile_run import MotileRun


Expand Down Expand Up @@ -91,14 +96,29 @@ def __init__(self):
self.tracks_list.setSelectionMode(1) # single selection
self.tracks_list.itemSelectionChanged.connect(self._selection_changed)

load_button = QPushButton("Load tracks")
load_menu = QHBoxLayout()
self.dropdown_menu = QComboBox()
self.dropdown_menu.addItems(["Motile Run", "External tracks from CSV"])

load_button = QPushButton("Load")
load_button.clicked.connect(self.load_tracks)

load_menu.addWidget(self.dropdown_menu)
load_menu.addWidget(load_button)

layout = QVBoxLayout()
layout.addWidget(self.tracks_list)
layout.addWidget(load_button)
layout.addLayout(load_menu)
self.setLayout(layout)

def _load_external_tracks(self):
dialog = ImportTracksDialog()
if dialog.exec_() == QDialog.Accepted:
tracks = dialog.tracks
name = dialog.name
if tracks is not None:
self.add_tracks(tracks, name, select=True)

def _selection_changed(self):
selected = self.tracks_list.selectedItems()
if selected:
Expand Down Expand Up @@ -151,9 +171,19 @@ def remove_tracks(self, item: QListWidgetItem):
self.tracks_list.takeItem(row)

def load_tracks(self):
"""Call the function to load tracks from disk for a Motile Run or for externally generated tracks (CSV file),
depending on the choice in the dropdown menu."""

if self.dropdown_menu.currentText() == "Motile Run":
self.load_motile_run()
elif self.dropdown_menu.currentText() == "External tracks from CSV":
self._load_external_tracks()

def load_motile_run(self):
"""Load a set of tracks from disk. The user selects the directory created
by calling save_tracks.
"""

if self.file_dialog.exec_():
directory = Path(self.file_dialog.selectedFiles()[0])
name = directory.stem
Expand All @@ -162,7 +192,7 @@ def load_tracks(self):
self.add_tracks(tracks, name, select=True)
except (ValueError, FileNotFoundError):
try:
tracks = Tracks.load(directory)
tracks = SolutionTracks.load(directory)
self.add_tracks(tracks, name, select=True)
except (ValueError, FileNotFoundError) as e:
warn(f"Could not load tracks from {directory}: {e}", stacklevel=2)
2 changes: 2 additions & 0 deletions src/motile_tracker/import_export/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .menus.import_external_tracks_dialog import ImportTracksDialog # noqa
from .load_tracks import tracks_from_df # noqa
Loading

0 comments on commit 85b6244

Please sign in to comment.