Skip to content

Commit

Permalink
kymotracking: allow importing KymoTrackGroup
Browse files Browse the repository at this point in the history
  • Loading branch information
JoepVanlier committed Dec 2, 2024
1 parent 009fa10 commit ca5b8a0
Show file tree
Hide file tree
Showing 9 changed files with 188 additions and 76 deletions.
2 changes: 2 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* Added parameter `titles` to customize title of each subplot in [`Kymo.plot_with_channels()`](https://lumicks-pylake.readthedocs.io/en/latest/_api/lumicks.pylake.kymo.Kymo.html#lumicks.pylake.kymo.Kymo.plot_with_channels).
* Added [`KymoTrack.sample_from_channel()`](https://lumicks-pylake.readthedocs.io/en/latest/_api/lumicks.pylake.kymotracker.kymotrack.KymoTrack.html#lumicks.pylake.kymotracker.kymotrack.KymoTrack.sample_from_channel) to downsample channel data to the time points of a kymotrack.
* Added support for file names with spaces in [`lk.download_from_doi()`](https://lumicks-pylake.readthedocs.io/en/latest/_api/lumicks.pylake.download_from_doi.html#lumicks.pylake.download_from_doi).
* Added function to import a [`KymoTrackGroup`](https://lumicks-pylake.readthedocs.io/en/latest/_api/lumicks.pylake.kymotracker.kymotrack.KymoTrackGroup.html) from a `CSV` file using [`load_tracks`](https://lumicks-pylake.readthedocs.io/en/latest/_api/lumicks.pylake.load_tracks.html).
* Added function to load tracks into the kymotracker widget using [`KymoWidgetGreedy.load_tracks()`](https://lumicks-pylake.readthedocs.io/en/latest/_api/lumicks.pylake.KymoWidgetGreedy.html#lumicks.pylake.KymoWidgetGreedy.load_tracks).

#### Improvements

Expand Down
2 changes: 2 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ Kymotracking
filter_tracks
refine_tracks_centroid
refine_tracks_gaussian
load_tracks


Notebook widgets
----------------
Expand Down
13 changes: 11 additions & 2 deletions docs/tutorial/kymotracking.rst
Original file line number Diff line number Diff line change
Expand Up @@ -471,8 +471,8 @@ This snippet also demonstrates how we can pass keyword arguments (forwarded to :
<matplotlib.pyplot.bar()>`) to format the histogram.


Exporting kymograph tracks
--------------------------
Importing and exporting kymograph tracks
----------------------------------------

We can export the coordinates of the tracks to a `csv` file using the :meth:`~lumicks.pylake.kymotracker.kymotrack.KymoTrackGroup.save`
method with the desired file name::
Expand All @@ -484,6 +484,15 @@ by passing a width in pixels to sum counts over::

tracks.save("tracks_signal.csv", sampling_width=3, correct_origin=True)

We can re-import these tracks into Pylake at a later time using the :func:`~lumicks.pylake.load_tracks()` function::

tracks = lk.load_tracks("tracks.csv", kymo40, "green")

.. note::

The `kymo` and `channel` arguments provided must be the same as the ones used to generate the tracks.
This includes any processing (e.g. cropping, downsampling, flipping) that was performed on the kymograph.

How the algorithms work
-----------------------

Expand Down
1 change: 1 addition & 0 deletions lumicks/pylake/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from .image_stack import ImageStack, CorrelatedStack
from .file_download import *
from .fitting.models import *
from .kymotracker.kymotrack import load_tracks
from .fitting.parameter_trace import parameter_trace
from .kymotracker.kymotracker import *
from .piezo_tracking.baseline import *
Expand Down
11 changes: 10 additions & 1 deletion lumicks/pylake/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,17 @@ class File(Group, Force, DownsampledFD, BaselineCorrectedForce, PhotonCounts, Ph
file.force1x.plot()
file.kymos["name"].plot()
# Open with custom detector mapping
# Open file mapping the red photon channel to red and the blue photon channel to green and
# blue (making them appear cyan).
file = pylake.File("example.h5", rgb_to_detectors={"Red": "Red", "Green": "Blue", "Blue": "Blue"})
# For some systems, the detectors might be named differently (in the case of custom
# detectors).
file = pylake.File("example.h5", rgb_to_detectors={"Red": "Detector 1", "Green": "Detector 2", "Blue": "Detector 3"})
# To see which channels are available, inspect which names are available in the group
# `Photon count` or `Photon time tags`.
print(file["Photon count"])
"""

SUPPORTED_FILE_FORMAT_VERSIONS = [1, 2]
Expand Down
110 changes: 77 additions & 33 deletions lumicks/pylake/kymotracker/kymotrack.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def _read_txt(file, delimiter):
except TypeError:
# Direct StringIO
header_lines = [file.readline(), file.readline()]
except UnicodeDecodeError as e:
raise ValueError(
f"Invalid file format. Expected comma separated text file. Full error message: {e}."
) from None

# from v0.13.0, exported CSV files have an additional header line
# with the pylake version and CSV version (starting at 2)
Expand All @@ -52,8 +56,8 @@ def _read_txt(file, delimiter):
data = {}
try:
raw_data = np.loadtxt(file, delimiter=delimiter, unpack=True)
except ValueError:
raise IOError("Invalid file format!")
except ValueError as e:
raise ValueError(f"Invalid file format: {str(e)}") from None

header = header_lines[0].rstrip().split(delimiter)
track_idx = raw_data[0, :]
Expand Down Expand Up @@ -167,50 +171,66 @@ def store_column(column_title, format_string, new_data):
def _check_summing_mismatch(track, sampling_width):
"""Checks calling sample_from_image on a loaded track reproduces the same result as is in the
file."""
wrong_kymo_warning = (
"Photon counts do not match the photon counts found in the file. It is "
"possible that the loaded kymo or channel doesn't match the one used to "
"create this file."
)
try:
if not np.allclose(
if np.allclose(
track.sample_from_image((sampling_width - 1) // 2, correct_origin=True),
track.photon_counts,
):
if np.allclose(
track.sample_from_image((sampling_width - 1) // 2, correct_origin=False),
track.photon_counts,
):
return RuntimeWarning(
"Photon counts do not match the photon counts found in the file. Prior to "
"Pylake v1.1.0, the method `sample_from_image` had a bug that assumed the "
"origin of a pixel to be at the edge rather than the center of the pixel. "
"Consequently, the sampled window could be off by one pixel. This file was "
"likely created using the incorrect origin. "
"Note that Pylake loaded the counts found in the file as is, so if the "
"used summing window was very small, there may be a bias in the counts."
"To recreate these counts without bias invoke:"
f"`track.sample_from_image({(sampling_width - 1) // 2}, correct_origin=True)`"
)
else:
return RuntimeWarning(wrong_kymo_warning)
return # We're good

if np.allclose(
track.sample_from_image((sampling_width - 1) // 2, correct_origin=False),
track.photon_counts,
):
return RuntimeWarning(
"Photon counts do not match the photon counts found in the file. Prior to "
"Pylake v1.1.0, the method `sample_from_image` had a bug that assumed the "
"origin of a pixel to be at the edge rather than the center of the pixel. "
"Consequently, the sampled window could be off by one pixel. This file was "
"likely created using the incorrect origin. "
"Note that Pylake loaded the counts found in the file as is, so if the "
"used summing window was very small, there may be a bias in the counts."
"To recreate these counts without bias invoke:"
f"`track.sample_from_image({(sampling_width - 1) // 2}, correct_origin=True)`"
)
else:
return RuntimeWarning(
"Photon counts do not match the photon counts found in the file. It is "
"possible that the loaded kymo or channel doesn't match the one used to "
"create this file. Note that if you processed (e.g. sliced, cropped, flipped or "
"downsampled) the kymograph prior to tracking, you will have to make sure that you "
"crop the kymograph you supply to this function in the same way."
)
except IndexError:
return RuntimeWarning(wrong_kymo_warning)
raise ValueError(
"The supplied kymograph is of a different duration or size than the one used to "
"compute these tracks. The kymograph does not match the tracks found in the file. Note "
"that if you processed (e.g. sliced, cropped or downsampled) the kymograph prior to "
"tracking, you will have to make sure that you crop the kymograph you supply to this "
"function in the same way."
)


def import_kymotrackgroup_from_csv(filename, kymo, channel, delimiter=";"):
"""Import a KymoTrackGroup from a csv file.
def load_tracks(filename, kymo, channel, delimiter=";"):
"""Loads a :class:`~lumicks.pylake.kymotracker.kymotrack.KymoTrackGroup` from a csv file.
The file format contains a series of columns as follows:
track index, time (pixels), coordinate (pixels), time (optional), coordinate (optional),
sampled_counts (optional), minimum length
.. note::
If you processed (e.g. cropping, flipping, downsampling) the kymograph prior to tracking,
you will have to make sure that you crop the kymograph you supply to this function in the
same way.
Parameters
----------
filename : str | os.PathLike
filename to import from.
kymo : Kymo
kymograph instance that the CSV data was tracked from.
Kymograph that the CSV data was tracked from. This kymograph has to be the one used to
create the tracks (this includes any processing). See the note above for more information.
channel : str
color channel that was used for tracking.
delimiter : str
Expand All @@ -223,16 +243,37 @@ def import_kymotrackgroup_from_csv(filename, kymo, channel, delimiter=";"):
Raises
------
IOError
ValueError
If the file format is not as expected.
Examples
--------
::
import lumicks.pylake as lk
file = lk.File("test_data/kymo.h5")
kymo = file.kymos["16"] # Extract the kymo named 16
kymo_cropped = kymo.crop_by_distance(10, 25)
tracks = lk.track_greedy(kymo_cropped, channel="red", pixel_threshold=5)
tracks.save("tracks.csv")
loaded_tracks = lk.load_tracks("tracks.csv", kymo_cropped, "red")
# Plot the red channel of the kymograph and tracks together
kymo_cropped.plot("red", adjustment=lk.ColorAdjustment(5, 95, "percentile"))
loaded_tracks.plot()
"""

# TODO: File format validation could use some improvement
data, pylake_version, csv_version = _read_txt(filename, delimiter)

mandatory_fields = ["time (pixels)", "coordinate (pixels)"]
if not all(field_name in data for field_name in mandatory_fields):
raise IOError("Invalid file format!")
raise ValueError(
f"Invalid file format. Missing field(s): {', '.join(set(data.keys()) - set(mandatory_fields))}"
)

# We get a list of time coordinates per track
for track_time in data["time (pixels)"]:
Expand Down Expand Up @@ -292,7 +333,7 @@ def create_track(time, coord, min_length=None, counts=None):
)
)

if sampling_width is not None:
if sampling_width is not None and not resampling_mismatch:
resampling_mismatch = _check_summing_mismatch(tracks[-1], sampling_width)

if resampling_mismatch:
Expand Down Expand Up @@ -1077,7 +1118,7 @@ class KymoTrackGroup:
--------
::
from lumicks import pylake
import lumicks.pylake as lk
tracks = lk.track_greedy(kymo, channel="red", pixel_threshold=5)
Expand Down Expand Up @@ -1498,6 +1539,9 @@ def plot_fit(self, frame_idx, *, fit_kwargs=None, data_kwargs=None, show_track_i
import lumicks.pylake as lk
file = lk.File("test_data/kymo.h5")
kymo = file.kymos["16"] # Extract the kymo named 16
tracks = lk.track_greedy(kymo, channel="red", pixel_threshold=5)
refined = lk.refine_tracks_gaussian(tracks, window=10, refine_missing_frames=True, overlap_strategy="multiple")
Expand Down
57 changes: 25 additions & 32 deletions lumicks/pylake/kymotracker/tests/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,7 @@
import pytest

from lumicks.pylake.kymo import _kymo_from_array
from lumicks.pylake.kymotracker.kymotrack import (
KymoTrack,
KymoTrackGroup,
_read_txt,
import_kymotrackgroup_from_csv,
)
from lumicks.pylake.kymotracker.kymotrack import KymoTrack, KymoTrackGroup, _read_txt, load_tracks
from lumicks.pylake.tests.data.mock_confocal import generate_kymo


Expand Down Expand Up @@ -80,7 +75,7 @@ def test_kymotrackgroup_io(tmpdir_factory, dt, dx, delimiter, sampling_width, sa
# Test round trip through the API
testfile = f"{tmpdir_factory.mktemp('pylake')}/test.csv"
tracks.save(testfile, delimiter, sampling_width, correct_origin=True)
imported_tracks = import_kymotrackgroup_from_csv(testfile, kymo, "red", delimiter=delimiter)
imported_tracks = load_tracks(testfile, kymo, "red", delimiter=delimiter)
data, pylake_version, csv_version = _read_txt(testfile, delimiter)

compare_kymotrack_group(tracks, imported_tracks)
Expand Down Expand Up @@ -146,9 +141,7 @@ def get_args(func):
string_representation = s.getvalue()

with io.StringIO(string_representation) as s:
read_tracks = import_kymotrackgroup_from_csv(
s, kymo_integration_test_data, "red", delimiter=delimiter
)
read_tracks = load_tracks(s, kymo_integration_test_data, "red", delimiter=delimiter)

compare_kymotrack_group(kymo_integration_tracks, read_tracks)

Expand All @@ -166,31 +159,22 @@ def test_photon_count_validation(kymo_integration_test_data, kymo_integration_tr
RuntimeWarning,
match="origin of a pixel to be at the edge rather than the center of the pixel",
):
_ = import_kymotrackgroup_from_csv(
io.StringIO(biased_tracks), kymo_integration_test_data, "red"
)
_ = load_tracks(io.StringIO(biased_tracks), kymo_integration_test_data, "red")

# We can also fail by having the wrong kymo
with pytest.warns(
RuntimeWarning,
match="loaded kymo or channel doesn't match the one used to create this file",
):
_ = import_kymotrackgroup_from_csv(
io.StringIO(good_tracks), kymo_integration_test_data, "green"
)
_ = load_tracks(io.StringIO(good_tracks), kymo_integration_test_data, "green")

# Or by having the wrong one where it actually completely fails to sample. This tests whether
# the exception inside import_kymotrackgroup_from_csv is correctly caught and handled
with pytest.warns(
RuntimeWarning,
match="loaded kymo or channel doesn't match the one used to create this file",
):
_ = import_kymotrackgroup_from_csv(
io.StringIO(good_tracks), kymo_integration_test_data[:"1s"], "red"
)
# the exception inside load_tracks is correctly caught and handled
with pytest.raises(ValueError, match="kymograph is of a different duration or size"):
_ = load_tracks(io.StringIO(good_tracks), kymo_integration_test_data[:"1s"], "red")

# Control for good tracks
import_kymotrackgroup_from_csv(io.StringIO(good_tracks), kymo_integration_test_data, "red")
load_tracks(io.StringIO(good_tracks), kymo_integration_test_data, "red")


@pytest.mark.parametrize(
Expand Down Expand Up @@ -222,7 +206,7 @@ def test_csv_version(version, read_with_version, recwarn):
)

testfile = Path(__file__).parent / f"./data/tracks_v{version}.csv"
imported_tracks = import_kymotrackgroup_from_csv(testfile, kymo, "red", delimiter=";")
imported_tracks = load_tracks(testfile, kymo, "red", delimiter=";")

match version:
case 3:
Expand All @@ -244,11 +228,20 @@ def test_csv_version(version, read_with_version, recwarn):
np.testing.assert_allclose(track.coordinate_idx, data["coordinate (pixels)"][j])


@pytest.mark.parametrize("filename", ["csv_bad_format.csv", "csv_unparseable.csv"])
def test_bad_csv(filename, blank_kymo):
with pytest.raises(IOError, match="Invalid file format!"):
@pytest.mark.parametrize(
"filename, error",
[
("csv_bad_format.csv", "Invalid file format. Missing field(s): # track index"),
(
"csv_unparseable.csv",
"Invalid file format: could not convert string '' to float64 at row 0, column 3.",
),
],
)
def test_bad_csv(filename, error, blank_kymo):
with pytest.raises(ValueError, match=re.escape(error)):
file = Path(__file__).parent / "data" / filename
import_kymotrackgroup_from_csv(file, blank_kymo, "red", delimiter=";")
load_tracks(file, blank_kymo, "red", delimiter=";")


def test_min_obs_csv_regression(tmpdir_factory, blank_kymo):
Expand All @@ -258,7 +251,7 @@ def test_min_obs_csv_regression(tmpdir_factory, blank_kymo):
RuntimeWarning,
match="loaded kymo or channel doesn't match the one used to create this file",
):
imported_tracks = import_kymotrackgroup_from_csv(testfile, blank_kymo, "red", delimiter=";")
imported_tracks = load_tracks(testfile, blank_kymo, "red", delimiter=";")

out_file = f"{tmpdir_factory.mktemp('pylake')}/no_min_lengths.csv"

Expand All @@ -268,4 +261,4 @@ def test_min_obs_csv_regression(tmpdir_factory, blank_kymo):

out_file2 = f"{tmpdir_factory.mktemp('pylake')}/no_min_lengths2.csv"
with pytest.warns(RuntimeWarning, match=err_msg):
import_kymotrackgroup_from_csv(out_file, blank_kymo, "red", delimiter=";").save(out_file2)
load_tracks(out_file, blank_kymo, "red", delimiter=";").save(out_file2)
Loading

0 comments on commit ca5b8a0

Please sign in to comment.