Skip to content

Commit

Permalink
kymo: add optional tether ends to calibrate_to_kbp
Browse files Browse the repository at this point in the history
# Conflicts:
#	lumicks/pylake/tests/test_imaging_confocal/test_kymo_transforms.py
  • Loading branch information
rpauszek committed Jan 29, 2025
1 parent 513ef0f commit e512751
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 17 deletions.
38 changes: 32 additions & 6 deletions lumicks/pylake/kymo.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,8 +394,8 @@ def plot(
extent=[
-0.5 * self.line_time_seconds,
self.duration - 0.5 * self.line_time_seconds,
size_calibrated - 0.5 * self.pixelsize[0],
-0.5 * self.pixelsize[0],
self._calibration.from_pixels(self._num_pixels[0] - 0.5),
self._calibration.from_pixels(-0.5),
],
aspect=(image.shape[0] / image.shape[1]) * (self.duration / size_calibrated),
cmap=colormaps._get_default_colormap(channel),
Expand Down Expand Up @@ -998,19 +998,36 @@ def image_factory(_, channel):

return result

def calibrate_to_kbp(self, length_kbp):
def calibrate_to_kbp(self, length_kbp, *, start=None, end=None):
"""Calibrate from microns to other units.
Parameters
----------
length : float
length of the kymo in kilobase pairs
length_kbp : float
length of the tether in kilobase pairs
start: float | None
start point of the tether in microns
end: float | None
end point of the tether in microns
"""
if self._calibration.unit == PositionUnit.kbp:
raise RuntimeError("kymo is already calibrated in base pairs.")

if (start is None) ^ (end is None):
raise ValueError("Both start and end points of the tether must be supplied.")

if start is not None:
if end < start:
raise ValueError("end must be larger than start.")
kbp_per_pixel = length_kbp / (end - start) * self.pixelsize_um[0]
else:
kbp_per_pixel = length_kbp / self._num_pixels[0]
pixel_origin = start / self.pixelsize_um[0] if start is not None else 0.0

result = copy(self)
result._calibration = PositionCalibration(PositionUnit.kbp, length_kbp / self._num_pixels[0])
result._calibration = PositionCalibration(
PositionUnit.kbp, kbp_per_pixel, origin=pixel_origin
)
result._image_factory = self._image_factory
result._timestamp_factory = self._timestamp_factory
result._line_time_factory = self._line_time_factory
Expand Down Expand Up @@ -1107,6 +1124,15 @@ def label(self):
class PositionCalibration:
unit: PositionUnit = PositionUnit.pixel
value: float = 1.0
origin: float = 0.0

def from_pixels(self, pixels):
"""Convert coordinates from pixel values to calibrated values"""
return self.value * (np.array(pixels) - self.origin)

def to_pixels(self, calibrated):
"""Convert coordinates from calibrated values to pixel values"""
return np.array(calibrated) / self.value + self.origin

@property
def unit_label(self):
Expand Down
13 changes: 8 additions & 5 deletions lumicks/pylake/kymotracker/kymotrack.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,11 @@ def create_track(time, coord, min_length=None, counts=None):
if min_length is not None:
min_length = float(np.unique(min_length).squeeze())

if counts is not None:
coord = CentroidLocalizationModel(coord * kymo.pixelsize_um, counts)
coord = (
CentroidLocalizationModel(kymo._calibration.from_pixels(coord), counts)
if counts is not None
else LocalizationModel(kymo._calibration.from_pixels(coord))
)

return KymoTrack(time.astype(int), coord, kymo, channel, min_length)

Expand Down Expand Up @@ -381,7 +384,7 @@ def __init__(self, time_idx, localization, kymo, channel, minimum_observable_dur
self._localization = (
localization
if isinstance(localization, LocalizationModel)
else LocalizationModel(np.array(localization) * self._pixelsize)
else LocalizationModel(kymo._calibration.from_pixels(localization))
)

@property
Expand Down Expand Up @@ -458,7 +461,7 @@ def _from_centroid_estimate(
return cls(
time_idx,
CentroidLocalizationModel(
coordinate_idx * kymo.pixelsize[0],
kymo._calibration.from_pixels(coordinate_idx),
np.array(
_sum_track_signal(
kymo.get_image(channel),
Expand Down Expand Up @@ -529,7 +532,7 @@ def coordinate_idx(self):
Coordinates are defined w.r.t. pixel centers (i.e. 0, 0 is the center of the first pixel).
"""
return self._localization.position / self._kymo.pixelsize[0]
return self._kymo._calibration.to_pixels(self.position)

@property
def seconds(self):
Expand Down
30 changes: 30 additions & 0 deletions lumicks/pylake/kymotracker/tests/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,36 @@ def get_args(func):
compare_kymotrack_group(kymo_integration_tracks, read_tracks)


def test_roundtrip_with_calibration(tmpdir_factory):
path = tmpdir_factory.mktemp("pylake")
filenames = (f"{path}/tracks_microns.csv", f"{path}/tracks_bp.csv")

image = np.ones((10, 15, 3))
kymo_um = _kymo_from_array(image, "rgb", line_time_seconds=0.1, pixel_size_um=0.050)
kymo_bp = kymo_um.calibrate_to_kbp(10.0, start=0.05, end=0.15)

time_coords = [np.array(x) for x in ([1, 2, 3], [2, 3, 4, 5])]
pos_coords = [np.array(x) for x in ([1, 2, 1], [3.2, 4.8, 3.5, 6.9])]

ref_tracks = [
KymoTrackGroup(
[KymoTrack(t, x, kymo, "green", 0.1) for t, x in zip(time_coords, pos_coords)]
)
for kymo in (kymo_um, kymo_bp)
]

for j, filename in enumerate(filenames):
ref_tracks[j].save(filename)

loaded_tracks = [
load_tracks(filename, kymo, "green")
for filename, kymo in zip(filenames, (kymo_um, kymo_bp))
]

for ref, tracks in zip(ref_tracks, loaded_tracks):
compare_kymotrack_group(ref, tracks)


def test_photon_count_validation(kymo_integration_test_data, kymo_integration_tracks):
with io.StringIO() as s:
kymo_integration_tracks.save(s, sampling_width=0, correct_origin=False)
Expand Down
65 changes: 60 additions & 5 deletions lumicks/pylake/tests/test_imaging_confocal/test_kymo_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,69 @@ def test_plotting(test_kymo):
-(pixel_size / 2),
],
)

# test original kymo is labeled with microns and
# that kymo calibrated with base pairs has appropriate label
assert plt.gca().get_ylabel() == r"position (μm)"
plt.close()

kymo_bp = kymo.calibrate_to_kbp(10.000)
kymo_bp.plot(channel="red")

def test_plotting_calibrated(test_kymo):
kymo, ref = test_kymo
line_time = ref.timestamps.line_time_seconds
n_lines = ref.metadata.lines_per_frame
n_pixels = ref.metadata.pixels_per_line

tether_length = 10.000
kbp_per_pixel = tether_length / n_pixels

kymo_bp = kymo.calibrate_to_kbp(tether_length)
kymo_bp.plot(channel="red", interpolation="none")

image = plt.gca().get_images()[0]
np.testing.assert_allclose(
image.get_extent(),
[
-0.5 * line_time,
(n_lines - 0.5) * line_time,
(n_pixels * kbp_per_pixel - (kbp_per_pixel / 2)),
-(kbp_per_pixel / 2),
],
)

assert plt.gca().get_ylabel() == "position (kbp)"
plt.close()


@pytest.mark.parametrize(
"start,end,tether_length",
(
(0.1, 0.3, 10.0),
(0.085, 0.321, 10.0),
(0.24, 0.38, 5.234),
(0.1, 0.52, 10.0), # end > kymo length
(-0.2, 0.3, 10.0), # start < kymo start
(-0.267, 0.583, 15.325), # both ends outside of image
),
)
def test_plotting_calibrated_with_ends(test_kymo, start, end, tether_length):
kymo, ref = test_kymo
line_time = ref.timestamps.line_time_seconds
n_lines = ref.metadata.lines_per_frame
n_pixels = ref.metadata.pixels_per_line
pixel_size = ref.metadata.pixelsize_um[0]

n_tether_pixels = (end - start) / pixel_size
kbp_per_pixel = tether_length / n_tether_pixels
pixels_skipped = start / pixel_size
min_extent = -(pixels_skipped + 0.5) * kbp_per_pixel
max_extent = min_extent + (n_pixels * kbp_per_pixel)

kymo_bp = kymo.calibrate_to_kbp(tether_length, start=start, end=end)

kymo_bp.plot(channel="red", interpolation="none")
image = plt.gca().get_images()[0]
np.testing.assert_allclose(
image.get_extent(),
[-0.5 * line_time, (n_lines - 0.5) * line_time, max_extent, min_extent],
)
assert plt.gca().get_ylabel() == "position (kbp)"
plt.close()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import numpy as np
import pytest

from lumicks.pylake.kymo import PositionUnit
from lumicks.pylake.kymo import PositionUnit, PositionCalibration


def test_calibrate_to_kbp(test_kymo):
Expand All @@ -28,6 +28,28 @@ def test_calibrate_to_kbp(test_kymo):
np.testing.assert_allclose(kymo_bp._calibration.value * n_pixels, length_kbp)
np.testing.assert_allclose(kymo_bp.pixelsize, length_kbp / n_pixels)

start = 0.12
end = 0.33
n_pixels_tether = (end - start) / ref.metadata.pixelsize_um[0]

kymo_bp = kymo.calibrate_to_kbp(length_kbp, start=start, end=end)
np.testing.assert_allclose(kymo_bp._calibration.value * n_pixels_tether, length_kbp)
np.testing.assert_allclose(kymo_bp.pixelsize, length_kbp / n_pixels_tether)

with pytest.raises(ValueError, match="end must be larger than start."):
kymo.calibrate_to_kbp(length_kbp, start=end, end=start)

with pytest.raises(RuntimeError, match="kymo is already calibrated in base pairs."):
kymo_bp.calibrate_to_kbp(10)


@pytest.mark.parametrize("start, end", [(None, 0.33), (0.12, None)])
def test_calibrate_to_kbp_invalid_range(test_kymo, start, end):
with pytest.raises(
ValueError, match="Both start and end points of the tether must be supplied."
):
test_kymo[0].calibrate_to_kbp(1, start=start, end=end)


def check_factory_forwarding(kymo1, kymo2, check_timestamps):
"""test that all factories were forwarded from original instance"""
Expand Down Expand Up @@ -117,3 +139,19 @@ def test_position_unit():
PositionUnit.um,
PositionUnit.kbp,
}


def test_coordinate_transforms():
px_coord = [0, 1.2, 3.14, 85]

c = PositionCalibration("kbp", value=0.42)
kbp_coord = [0, 0.504, 1.3188, 35.7]
transformed = c.from_pixels(px_coord)
np.testing.assert_allclose(kbp_coord, transformed)
np.testing.assert_allclose(px_coord, c.to_pixels(transformed))

c = PositionCalibration("kbp", value=0.42, origin=2.0)
kbp_coord = [-0.84, -0.336, 0.4788, 34.86]
transformed = c.from_pixels(px_coord)
np.testing.assert_allclose(kbp_coord, transformed)
np.testing.assert_allclose(px_coord, c.to_pixels(transformed))

0 comments on commit e512751

Please sign in to comment.