Skip to content

Commit c002cf5

Browse files
committed
feat: tracking of sarcomere vectors by computation of optical flow, position advection, and vector matching
1 parent ddc620c commit c002cf5

4 files changed

Lines changed: 1836 additions & 183 deletions

File tree

sarcasm/structure.py

Lines changed: 183 additions & 183 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
kymograph,
4141
detection,
4242
loi_detection,
43-
# sarcomere_tracking
43+
sarcomere_tracking,
4444
)
4545

4646
class Structure(SarcAsM):
@@ -1615,188 +1615,188 @@ def delete_lois(self):
16151615
except Exception as e:
16161616
logger.debug(f"Error deleting LOI directory: {e}. Continuing anyway.")
16171617

1618-
# def track_sarcomere_vectors(
1619-
# self,
1620-
# frames: Union[str, int, List[int], np.ndarray] = 'all',
1621-
# max_distance: float = 0.5,
1622-
# memory: int = 5,
1623-
# min_track_length: int = 10,
1624-
# flow_blur_sigma: Optional[float] = None,
1625-
# use_hungarian: bool = True,
1626-
# length_weight: float = 2.0,
1627-
# return_flow: bool = False,
1628-
# progress_notifier: ProgressNotifier = ProgressNotifier.progress_notifier_tqdm()
1629-
# ) -> Optional[List[np.ndarray]]:
1630-
# """
1631-
# Track sarcomere vectors across frames using optical flow.
1632-
1633-
# This method uses dense optical flow computed on Z-band and M-band masks
1634-
# to predict vector motion between frames, then matches predicted positions
1635-
# to detected vectors using greedy exclusive assignment.
1636-
1637-
# Parameters
1638-
# ----------
1639-
# frames : {'all', int, list, np.ndarray}, optional
1640-
# Frames for tracking ('all' for all frames, int for a single frame,
1641-
# list or ndarray for selected frames). Defaults to 'all'.
1642-
# max_distance : float, optional
1643-
# Maximum matching distance in µm. Vectors predicted to be further
1644-
# than this from any detection are considered lost. Default 0.5 µm.
1645-
# memory : int, optional
1646-
# Number of frames to keep tracks "sleeping" before termination.
1647-
# Sleeping tracks can be re-linked if a matching vector reappears.
1648-
# Default 5 frames.
1649-
# min_track_length : int, optional
1650-
# Minimum track length to keep. Shorter tracks are filtered out
1651-
# and their vectors marked as untracked (track_id = -1). Default 10.
1652-
# flow_blur_sigma : float, optional
1653-
# Gaussian blur sigma (in pixels) to apply to masks before optical
1654-
# flow computation. Can help with noisy masks. Default None (no blur).
1655-
# use_hungarian : bool, optional
1656-
# If True, use Hungarian algorithm for optimal matching. This gives
1657-
# better results when vectors are densely packed. Default True.
1658-
# length_weight : float, optional
1659-
# Weight for sarcomere length difference in matching cost when using
1660-
# Hungarian algorithm. Higher values favor matching vectors with
1661-
# similar sarcomere lengths. Default 2.0.
1662-
# return_flow : bool, optional
1663-
# If True, also return the computed optical flow fields.
1664-
# Default False.
1665-
# progress_notifier : ProgressNotifier, optional
1666-
# Progress notifier for tracking progress.
1667-
1668-
# Returns
1669-
# -------
1670-
# flow_fields : list of np.ndarray, optional
1671-
# Only returned if return_flow=True. List of flow fields,
1672-
# each of shape (H, W, 2).
1673-
1674-
# Notes
1675-
# -----
1676-
# Results are stored in `self.data` with the following keys:
1677-
1678-
# - ``'track_ids'``: list of (N_t,) arrays with track ID for each vector
1679-
# per frame. Track ID of -1 indicates untracked vectors.
1680-
# - ``'n_tracks'``: total number of unique trajectories.
1681-
# - ``'track_lengths'``: (n_tracks,) array with number of frames each
1682-
# track spans.
1683-
# - ``'params.track_sarcomere_vectors.*'``: tracking parameters used.
1684-
1685-
# Requires :meth:`analyze_sarcomere_vectors` to be run first.
1686-
1687-
# Examples
1688-
# --------
1689-
# >>> structure.analyze_sarcomere_vectors(frames='all')
1690-
# >>> structure.track_sarcomere_vectors(max_distance=0.5, memory=5)
1691-
# >>> track_ids = structure.data['track_ids']
1692-
# >>> # track_ids[frame][vector_idx] gives the track ID for that vector
1693-
# """
1694-
# # Validate prerequisites
1695-
# if 'pos_vectors_px' not in self.data:
1696-
# raise ValueError(
1697-
# "Sarcomere vectors not yet analyzed. Run analyze_sarcomere_vectors first."
1698-
# )
1699-
1700-
# if not os.path.exists(self.file_zbands) or not os.path.exists(self.file_mbands):
1701-
# raise FileNotFoundError(
1702-
# "Z-band or M-band mask not found. Please run detect_sarcomeres first."
1703-
# )
1704-
1705-
# # Determine frames to track
1706-
# analyzed_frames = self.data.get('params.analyze_sarcomere_vectors.frames', [])
1707-
# if isinstance(frames, str) and frames == 'all':
1708-
# list_frames = list(range(self.metadata.n_stack))
1709-
# elif np.issubdtype(type(frames), np.integer):
1710-
# list_frames = [frames]
1711-
# elif isinstance(frames, (list, np.ndarray)):
1712-
# list_frames = [int(f) for f in frames]
1713-
# else:
1714-
# raise ValueError('frames argument not valid')
1715-
1716-
# # Validate frames have been analyzed
1717-
# if not set(list_frames).issubset(set(analyzed_frames)):
1718-
# missing = set(list_frames) - set(analyzed_frames)
1719-
# raise ValueError(
1720-
# f"Frames {missing} have not been analyzed. "
1721-
# f"Run analyze_sarcomere_vectors first for these frames."
1722-
# )
1723-
1724-
# logger.info(f"Starting sarcomere vector tracking for {len(list_frames)} frames...")
1725-
1726-
# # Load masks
1727-
# zbands_stack = tifffile.imread(self.file_zbands)
1728-
# mbands_stack = tifffile.imread(self.file_mbands)
1729-
1730-
# # Ensure 3D
1731-
# if zbands_stack.ndim == 2:
1732-
# zbands_stack = np.expand_dims(zbands_stack, axis=0)
1733-
# if mbands_stack.ndim == 2:
1734-
# mbands_stack = np.expand_dims(mbands_stack, axis=0)
1735-
1736-
# # Select frames
1737-
# zbands_stack = zbands_stack[list_frames]
1738-
# mbands_stack = mbands_stack[list_frames]
1739-
1740-
# # Prepare vector data for selected frames
1741-
# positions_per_frame = [self.data['pos_vectors_px'][f] for f in list_frames]
1742-
# lengths_per_frame = [self.data['sarcomere_length_vectors'][f] for f in list_frames]
1743-
# orientations_per_frame = [self.data['sarcomere_orientation_vectors'][f] for f in list_frames]
1744-
1745-
# # Run tracking
1746-
# result = sarcomere_tracking.track_sarcomere_vectors(
1747-
# zbands_stack=zbands_stack,
1748-
# mbands_stack=mbands_stack,
1749-
# positions_per_frame=positions_per_frame,
1750-
# sarcomere_lengths_per_frame=lengths_per_frame,
1751-
# orientations_per_frame=orientations_per_frame,
1752-
# pixelsize=self.metadata.pixelsize,
1753-
# max_distance=max_distance,
1754-
# memory=memory,
1755-
# min_track_length=min_track_length,
1756-
# flow_blur_sigma=flow_blur_sigma,
1757-
# use_hungarian=use_hungarian,
1758-
# length_weight=length_weight,
1759-
# return_flow=return_flow,
1760-
# progress_notifier=progress_notifier
1761-
# )
1762-
1763-
# if return_flow:
1764-
# tracking_result, flow_fields = result
1765-
# else:
1766-
# tracking_result = result
1767-
# flow_fields = None
1768-
1769-
# # Map track IDs back to full frame indices
1770-
# full_track_ids = [None] * self.metadata.n_stack
1771-
# for i, frame_idx in enumerate(list_frames):
1772-
# full_track_ids[frame_idx] = tracking_result.track_ids[i]
1773-
1774-
# # Store results in data dict
1775-
# tracking_dict = {
1776-
# 'params.track_sarcomere_vectors.frames': list_frames,
1777-
# 'params.track_sarcomere_vectors.max_distance': max_distance,
1778-
# 'params.track_sarcomere_vectors.memory': memory,
1779-
# 'params.track_sarcomere_vectors.min_track_length': min_track_length,
1780-
# 'params.track_sarcomere_vectors.flow_blur_sigma': flow_blur_sigma,
1781-
# 'params.track_sarcomere_vectors.use_hungarian': use_hungarian,
1782-
# 'params.track_sarcomere_vectors.length_weight': length_weight,
1783-
# 'track_ids': full_track_ids,
1784-
# 'n_tracks': tracking_result.n_tracks,
1785-
# 'track_lengths': tracking_result.track_lengths,
1786-
# }
1787-
# self.data.update(tracking_dict)
1788-
1789-
# if self.auto_save:
1790-
# self.store_structure_data()
1791-
1792-
# logger.info(
1793-
# f"Tracking complete: {tracking_result.n_tracks} tracks, "
1794-
# f"median length {np.median(tracking_result.track_lengths) if len(tracking_result.track_lengths) > 0 else 0:.0f} frames"
1795-
# )
1796-
1797-
# if return_flow:
1798-
# return flow_fields
1799-
# return None
1618+
def track_sarcomere_vectors(
1619+
self,
1620+
frames: Union[str, int, List[int], np.ndarray] = 'all',
1621+
max_distance: float = 0.5,
1622+
memory: int = 5,
1623+
min_track_length: int = 10,
1624+
flow_blur_sigma: Optional[float] = None,
1625+
use_hungarian: bool = True,
1626+
length_weight: float = 2.0,
1627+
return_flow: bool = False,
1628+
progress_notifier: ProgressNotifier = ProgressNotifier.progress_notifier_tqdm()
1629+
) -> Optional[List[np.ndarray]]:
1630+
"""
1631+
Track sarcomere vectors across frames using optical flow.
1632+
1633+
This method uses dense optical flow computed on Z-band and M-band masks
1634+
to predict vector motion between frames, then matches predicted positions
1635+
to detected vectors using greedy exclusive assignment.
1636+
1637+
Parameters
1638+
----------
1639+
frames : {'all', int, list, np.ndarray}, optional
1640+
Frames for tracking ('all' for all frames, int for a single frame,
1641+
list or ndarray for selected frames). Defaults to 'all'.
1642+
max_distance : float, optional
1643+
Maximum matching distance in µm. Vectors predicted to be further
1644+
than this from any detection are considered lost. Default 0.5 µm.
1645+
memory : int, optional
1646+
Number of frames to keep tracks "sleeping" before termination.
1647+
Sleeping tracks can be re-linked if a matching vector reappears.
1648+
Default 5 frames.
1649+
min_track_length : int, optional
1650+
Minimum track length to keep. Shorter tracks are filtered out
1651+
and their vectors marked as untracked (track_id = -1). Default 10.
1652+
flow_blur_sigma : float, optional
1653+
Gaussian blur sigma (in pixels) to apply to masks before optical
1654+
flow computation. Can help with noisy masks. Default None (no blur).
1655+
use_hungarian : bool, optional
1656+
If True, use Hungarian algorithm for optimal matching. This gives
1657+
better results when vectors are densely packed. Default True.
1658+
length_weight : float, optional
1659+
Weight for sarcomere length difference in matching cost when using
1660+
Hungarian algorithm. Higher values favor matching vectors with
1661+
similar sarcomere lengths. Default 2.0.
1662+
return_flow : bool, optional
1663+
If True, also return the computed optical flow fields.
1664+
Default False.
1665+
progress_notifier : ProgressNotifier, optional
1666+
Progress notifier for tracking progress.
1667+
1668+
Returns
1669+
-------
1670+
flow_fields : list of np.ndarray, optional
1671+
Only returned if return_flow=True. List of flow fields,
1672+
each of shape (H, W, 2).
1673+
1674+
Notes
1675+
-----
1676+
Results are stored in `self.data` with the following keys:
1677+
1678+
- ``'track_ids'``: list of (N_t,) arrays with track ID for each vector
1679+
per frame. Track ID of -1 indicates untracked vectors.
1680+
- ``'n_tracks'``: total number of unique trajectories.
1681+
- ``'track_lengths'``: (n_tracks,) array with number of frames each
1682+
track spans.
1683+
- ``'params.track_sarcomere_vectors.*'``: tracking parameters used.
1684+
1685+
Requires :meth:`analyze_sarcomere_vectors` to be run first.
1686+
1687+
Examples
1688+
--------
1689+
>>> structure.analyze_sarcomere_vectors(frames='all')
1690+
>>> structure.track_sarcomere_vectors(max_distance=0.5, memory=5)
1691+
>>> track_ids = structure.data['track_ids']
1692+
>>> # track_ids[frame][vector_idx] gives the track ID for that vector
1693+
"""
1694+
# Validate prerequisites
1695+
if 'pos_vectors_px' not in self.data:
1696+
raise ValueError(
1697+
"Sarcomere vectors not yet analyzed. Run analyze_sarcomere_vectors first."
1698+
)
1699+
1700+
if not os.path.exists(self.file_zbands) or not os.path.exists(self.file_mbands):
1701+
raise FileNotFoundError(
1702+
"Z-band or M-band mask not found. Please run detect_sarcomeres first."
1703+
)
1704+
1705+
# Determine frames to track
1706+
analyzed_frames = self.data.get('params.analyze_sarcomere_vectors.frames', [])
1707+
if isinstance(frames, str) and frames == 'all':
1708+
list_frames = list(range(self.metadata.n_stack))
1709+
elif np.issubdtype(type(frames), np.integer):
1710+
list_frames = [frames]
1711+
elif isinstance(frames, (list, np.ndarray)):
1712+
list_frames = [int(f) for f in frames]
1713+
else:
1714+
raise ValueError('frames argument not valid')
1715+
1716+
# Validate frames have been analyzed
1717+
if not set(list_frames).issubset(set(analyzed_frames)):
1718+
missing = set(list_frames) - set(analyzed_frames)
1719+
raise ValueError(
1720+
f"Frames {missing} have not been analyzed. "
1721+
f"Run analyze_sarcomere_vectors first for these frames."
1722+
)
1723+
1724+
logger.info(f"Starting sarcomere vector tracking for {len(list_frames)} frames...")
1725+
1726+
# Load masks
1727+
zbands_stack = tifffile.imread(self.file_zbands)
1728+
mbands_stack = tifffile.imread(self.file_mbands)
1729+
1730+
# Ensure 3D
1731+
if zbands_stack.ndim == 2:
1732+
zbands_stack = np.expand_dims(zbands_stack, axis=0)
1733+
if mbands_stack.ndim == 2:
1734+
mbands_stack = np.expand_dims(mbands_stack, axis=0)
1735+
1736+
# Select frames
1737+
zbands_stack = zbands_stack[list_frames]
1738+
mbands_stack = mbands_stack[list_frames]
1739+
1740+
# Prepare vector data for selected frames
1741+
positions_per_frame = [self.data['pos_vectors_px'][f] for f in list_frames]
1742+
lengths_per_frame = [self.data['sarcomere_length_vectors'][f] for f in list_frames]
1743+
orientations_per_frame = [self.data['sarcomere_orientation_vectors'][f] for f in list_frames]
1744+
1745+
# Run tracking
1746+
result = sarcomere_tracking.track_sarcomere_vectors(
1747+
zbands_stack=zbands_stack,
1748+
mbands_stack=mbands_stack,
1749+
positions_per_frame=positions_per_frame,
1750+
sarcomere_lengths_per_frame=lengths_per_frame,
1751+
orientations_per_frame=orientations_per_frame,
1752+
pixelsize=self.metadata.pixelsize,
1753+
max_distance=max_distance,
1754+
memory=memory,
1755+
min_track_length=min_track_length,
1756+
flow_blur_sigma=flow_blur_sigma,
1757+
use_hungarian=use_hungarian,
1758+
length_weight=length_weight,
1759+
return_flow=return_flow,
1760+
progress_notifier=progress_notifier
1761+
)
1762+
1763+
if return_flow:
1764+
tracking_result, flow_fields = result
1765+
else:
1766+
tracking_result = result
1767+
flow_fields = None
1768+
1769+
# Map track IDs back to full frame indices
1770+
full_track_ids = [None] * self.metadata.n_stack
1771+
for i, frame_idx in enumerate(list_frames):
1772+
full_track_ids[frame_idx] = tracking_result.track_ids[i]
1773+
1774+
# Store results in data dict
1775+
tracking_dict = {
1776+
'params.track_sarcomere_vectors.frames': list_frames,
1777+
'params.track_sarcomere_vectors.max_distance': max_distance,
1778+
'params.track_sarcomere_vectors.memory': memory,
1779+
'params.track_sarcomere_vectors.min_track_length': min_track_length,
1780+
'params.track_sarcomere_vectors.flow_blur_sigma': flow_blur_sigma,
1781+
'params.track_sarcomere_vectors.use_hungarian': use_hungarian,
1782+
'params.track_sarcomere_vectors.length_weight': length_weight,
1783+
'track_ids': full_track_ids,
1784+
'n_tracks': tracking_result.n_tracks,
1785+
'track_lengths': tracking_result.track_lengths,
1786+
}
1787+
self.data.update(tracking_dict)
1788+
1789+
if self.auto_save:
1790+
self.store_structure_data()
1791+
1792+
logger.info(
1793+
f"Tracking complete: {tracking_result.n_tracks} tracks, "
1794+
f"median length {np.median(tracking_result.track_lengths) if len(tracking_result.track_lengths) > 0 else 0:.0f} frames"
1795+
)
1796+
1797+
if return_flow:
1798+
return flow_fields
1799+
return None
18001800

18011801
def full_analysis_structure(self, frames='all'):
18021802
"""

0 commit comments

Comments
 (0)