Skip to content

Commit 12bbc65

Browse files
committed
kymotrack: add sample_from_channel
1 parent b630325 commit 12bbc65

File tree

5 files changed

+124
-1
lines changed

5 files changed

+124
-1
lines changed

changelog.md

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
* Added improved printing of calibration items under `channel.calibration` providing a more convenient overview of the items associated with a `Slice`.
1212
* Added improved printing of calibrations performed with `Pylake`.
1313
* 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).
14+
* 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.
1415

1516
## v1.5.2 | 2024-07-24
1617

Loading

docs/tutorial/kymotracking.rst

+26
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,32 @@ Here `num_pixels` is the number of pixels to sum on either side of the track.
397397
.. note::
398398
For tracks obtained from tracking or :func:`~lumicks.pylake.refine_tracks_centroid`, the photon counts found in the attribute :attr:`~lumicks.pylake.kymotracker.kymotrack.KymoTrack.photon_counts` are computed by :func:`~lumicks.pylake.kymotracker.kymotrack.KymoTrack.sample_from_image` using `num_pixels=np.ceil(track_width / pixelsize) // 2` where `track_width` is the track width used for tracking or refinement.
399399

400+
Averaging channel data over tracks
401+
----------------------------------
402+
403+
It is also possible to average channel data over the track using :meth:`~lumicks.pylake.kymotracker.Kymotrack.sample_from_channel()`.
404+
For example, let's find out what the force was during a particular track::
405+
406+
force_slice = file.force1x
407+
track_force = longest_track.sample_from_channel(force_slice, include_dead_time=True)
408+
409+
When you call this function with a :class:`~lumicks.pylake.Slice`, it returns another :class:`~lumicks.pylake.Slice` with the downsampled channel data.
410+
What happens is that for every point on the track, the correct kymograph scan line is looked up and the channel data is averaged over the entire duration of that scan line.
411+
The parameter `include_dead_time` specifies whether the time it takes the mirror to return to its initial position after each scan line should be included in this average.
412+
For tracks which have not been refined (see :ref:`localization_refinement`) the result from this function may skip some scan lines entirely.
413+
414+
We can plot these slices just like any other.
415+
Plotting this slice, we can see that the protein detaches shortly after the force drops::
416+
417+
plt.figure()
418+
force_slice.plot(label="force (whole file)")
419+
track_force.plot(start=force_slice.start, marker=".", label="force (longest track)")
420+
plt.legend(loc="upper left")
421+
422+
.. image:: figures/kymotracking/sample_from_channel.png
423+
424+
We plotted the track here putting the time zero at the start time of the entire force plot by passing its `start` time.
425+
400426
Plotting binding histograms
401427
---------------------------
402428

lumicks/pylake/kymotracker/kymotrack.py

+45-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import itertools
44
from copy import copy
55

6+
from ..channel import Slice, empty_slice
67
from ..__about__ import __version__
78
from ..detail.utilities import replace_key_aliases
89
from .detail.peakfinding import _sum_track_signal
@@ -592,7 +593,22 @@ def in_rect(self, rect, all_points=False):
592593
return criterion(np.logical_and(time_match, position_match))
593594

594595
def interpolate(self):
595-
"""Interpolate KymoTrack to whole pixel values"""
596+
"""Linearly Interpolates :class:`KymoTrack` to include all time points between the start
597+
and end time of the track.
598+
599+
By default, the kymotracker returns tracks that only include points where the sigal
600+
is above a detection thresholds. Consequently, the returned track may miss particular
601+
time points where the signal dropped below a certain level. This function interpolates
602+
the :class:`KymoTrack` such that *all* intermediate time points are present.
603+
604+
.. note::
605+
606+
This function *only* linearly interpolates and does not attempt to estimate where the
607+
peak signal intensity is located for those interpolated points. If this is desired
608+
instead please use a refinement method such as
609+
:func:`~lumicks.pylake.refine_tracks_centroid()` or
610+
:func:`~lumicks.pylake.refine_tracks_gaussian()`.
611+
"""
596612
interpolated_time = np.arange(int(np.min(self.time_idx)), int(np.max(self.time_idx)) + 1, 1)
597613
interpolated_coord = np.interp(interpolated_time, self.time_idx, self.coordinate_idx)
598614
return self._with_coordinates(interpolated_time, interpolated_coord)
@@ -621,6 +637,34 @@ def _split(self, node):
621637

622638
return before, after
623639

640+
def sample_from_channel(self, channel_slice, include_dead_time=True) -> Slice:
641+
"""Sample channel data using the time points corresponding to this track
642+
643+
For each time point in the track, sample the channel data corresponding to that kymograph
644+
scan line.
645+
646+
.. note::
647+
648+
Tracks may skip kymograph frames when the signal drops below a certain level. If you
649+
intend to sample every time point on the track, remember to interpolate the track
650+
first using :meth:`KymoTrack.interpolate()`
651+
652+
Parameters
653+
----------
654+
channel_slice : Slice
655+
Sample from this channel data.
656+
include_dead_time : bool
657+
Include the time that the mirror returns to its start position after each kymograph
658+
line.
659+
"""
660+
ts_ranges = self._kymo.line_timestamp_ranges(include_dead_time=include_dead_time)
661+
try:
662+
return channel_slice.downsampled_over(
663+
[ts_ranges[time_idx] for time_idx in self.time_idx]
664+
)
665+
except ValueError:
666+
return empty_slice
667+
624668
def sample_from_image(self, num_pixels, reduce=np.sum, *, correct_origin=None):
625669
"""Sample from image using coordinates from this KymoTrack.
626670

lumicks/pylake/kymotracker/tests/test_image_sampling.py

+49
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import pytest
55

66
from lumicks.pylake.kymo import _kymo_from_array
7+
from lumicks.pylake.channel import Slice, Continuous
8+
from lumicks.pylake.detail.imaging_mixins import _FIRST_TIMESTAMP
79
from lumicks.pylake.kymotracker.kymotrack import KymoTrack
810
from lumicks.pylake.kymotracker.kymotracker import track_greedy
911
from lumicks.pylake.tests.data.mock_confocal import generate_kymo
@@ -114,3 +116,50 @@ def test_origin_warning_sample_from_image():
114116
),
115117
):
116118
tracks[0].sample_from_image(0)
119+
120+
121+
@pytest.mark.parametrize(
122+
"time_idx, ref_dead_included, ref_dead_excluded",
123+
[
124+
([0, 1, 2], [14.5, 24.5, 34.5], [12.5, 22.5, 32.5]),
125+
([0, 2], [14.5, 34.5], [12.5, 32.5]),
126+
([], [], []),
127+
],
128+
)
129+
def test_sample_from_channel(time_idx, ref_dead_included, ref_dead_excluded):
130+
img = np.zeros((5, 5))
131+
kymo = _kymo_from_array(
132+
img,
133+
"r",
134+
line_time_seconds=1.0,
135+
exposure_time_seconds=0.6,
136+
start=_FIRST_TIMESTAMP + int(1e9),
137+
)
138+
139+
data = Slice(Continuous(np.arange(100), start=_FIRST_TIMESTAMP, dt=int(1e8))) # 10 Hz
140+
kymotrack = KymoTrack(time_idx, time_idx, kymo, "red", 0)
141+
142+
sampled = kymotrack.sample_from_channel(data)
143+
np.testing.assert_allclose(sampled.data, ref_dead_included)
144+
145+
sampled = kymotrack.sample_from_channel(data, include_dead_time=False)
146+
np.testing.assert_allclose(sampled.data, ref_dead_excluded)
147+
148+
149+
def test_sample_from_channel_out_of_bounds():
150+
kymo = _kymo_from_array(np.zeros((5, 5)), "r", line_time_seconds=1.0)
151+
data = Slice(Continuous(np.arange(100), start=0, dt=int(1e8)))
152+
kymotrack = KymoTrack([0, 6], [0, 6], kymo, "red", 0)
153+
154+
with pytest.raises(IndexError):
155+
kymotrack.sample_from_channel(data, include_dead_time=False)
156+
157+
158+
def test_sample_from_channel_no_overlap():
159+
img = np.zeros((5, 5))
160+
kymo = _kymo_from_array(img, "r", start=_FIRST_TIMESTAMP, line_time_seconds=int(1e8))
161+
data = Slice(Continuous(np.arange(100), start=kymo.stop + 100, dt=int(1e8)))
162+
kymotrack = KymoTrack([0, 1, 2], [0, 1, 2], kymo, "red", 0)
163+
164+
with pytest.raises(RuntimeError, match="No overlap"):
165+
_ = kymotrack.sample_from_channel(data)

0 commit comments

Comments
 (0)