From e512751cf55c653ba8001855fb4ebc2f350ac672 Mon Sep 17 00:00:00 2001 From: rpauszek Date: Wed, 22 Jan 2025 15:47:23 +0100 Subject: [PATCH] kymo: add optional tether ends to calibrate_to_kbp # Conflicts: # lumicks/pylake/tests/test_imaging_confocal/test_kymo_transforms.py --- lumicks/pylake/kymo.py | 38 +++++++++-- lumicks/pylake/kymotracker/kymotrack.py | 13 ++-- lumicks/pylake/kymotracker/tests/test_io.py | 30 +++++++++ .../test_kymo_plotting.py | 65 +++++++++++++++++-- .../test_kymo_transforms.py | 40 +++++++++++- 5 files changed, 169 insertions(+), 17 deletions(-) diff --git a/lumicks/pylake/kymo.py b/lumicks/pylake/kymo.py index 66341b35e..8cea0b197 100644 --- a/lumicks/pylake/kymo.py +++ b/lumicks/pylake/kymo.py @@ -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), @@ -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 @@ -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): diff --git a/lumicks/pylake/kymotracker/kymotrack.py b/lumicks/pylake/kymotracker/kymotrack.py index fbd002baa..cffc06423 100644 --- a/lumicks/pylake/kymotracker/kymotrack.py +++ b/lumicks/pylake/kymotracker/kymotrack.py @@ -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) @@ -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 @@ -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), @@ -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): diff --git a/lumicks/pylake/kymotracker/tests/test_io.py b/lumicks/pylake/kymotracker/tests/test_io.py index 354b6a410..4580a74b5 100644 --- a/lumicks/pylake/kymotracker/tests/test_io.py +++ b/lumicks/pylake/kymotracker/tests/test_io.py @@ -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) diff --git a/lumicks/pylake/tests/test_imaging_confocal/test_kymo_plotting.py b/lumicks/pylake/tests/test_imaging_confocal/test_kymo_plotting.py index 58a85223e..cfbcc8efa 100644 --- a/lumicks/pylake/tests/test_imaging_confocal/test_kymo_plotting.py +++ b/lumicks/pylake/tests/test_imaging_confocal/test_kymo_plotting.py @@ -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() diff --git a/lumicks/pylake/tests/test_imaging_confocal/test_kymo_transforms.py b/lumicks/pylake/tests/test_imaging_confocal/test_kymo_transforms.py index 198fa600c..ab9c3d111 100644 --- a/lumicks/pylake/tests/test_imaging_confocal/test_kymo_transforms.py +++ b/lumicks/pylake/tests/test_imaging_confocal/test_kymo_transforms.py @@ -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): @@ -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""" @@ -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))