epicure.trackmate_export

  1from datetime import datetime
  2from pathlib import Path
  3from typing import Dict, List
  4from xml.dom import minidom
  5import xml.etree.ElementTree as ET
  6#import cv2
  7#import matplotlib.pyplot as plt
  8import numpy as np
  9import pandas as pd
 10from scipy.cluster.hierarchy import DisjointSet
 11from skimage.measure import find_contours
 12
 13import epicure.Utils as ut
 14
 15
 16# TODO: deal with groups (store in MANUAL_SPOT_COLOR)
 17
 18SPOT_FEATS = [
 19    {"feature": "POSITION_X", "name": "X", "shortname": "X", "dimension": "POSITION", "isint": "false"},
 20    {"feature": "POSITION_Y", "name": "Y", "shortname": "Y", "dimension": "POSITION", "isint": "false"},
 21    {"feature": "POSITION_Z", "name": "Z", "shortname": "Z", "dimension": "POSITION", "isint": "false"},
 22    {"feature": "POSITION_T", "name": "T", "shortname": "T", "dimension": "TIME", "isint": "false"},
 23    {"feature": "FRAME", "name": "Frame", "shortname": "Frame", "dimension": "NONE", "isint": "true"},
 24    {"feature": "VISIBILITY", "name": "Visibility", "shortname": "Visibility", "dimension": "NONE", "isint": "true"},
 25    {"feature": "RADIUS", "name": "Radius", "shortname": "R", "dimension": "LENGTH", "isint": "false"},
 26    {"feature": "MANUAL_SPOT_COLOR", "name": "Manual spot color", "shortname": "Spot color", "dimension": "NONE", "isint": "true"},
 27]
 28
 29EDGE_FEATS = [
 30    {"feature": "SPOT_SOURCE_ID", "name": "Source spot ID", "shortname": "Source ID", "dimension": "NONE", "isint": "true"},
 31    {"feature": "SPOT_TARGET_ID", "name": "Target spot ID", "shortname": "Target ID", "dimension": "NONE", "isint": "true"},
 32]
 33
 34TRACK_FEATS = [
 35    {"feature": "TRACK_INDEX", "name": "Track index", "shortname": "Index", "dimension": "NONE", "isint": "true"},
 36    {"feature": "TRACK_ID", "name": "Track ID", "shortname": "ID", "dimension": "NONE", "isint": "true"},
 37]
 38
 39
 40def build_feat_declaration_tag():
 41    """Build the FeatureDeclarations tag for TrackMate XML."""
 42    feat_declarations = ET.Element("FeatureDeclarations")
 43    spot_feats = ET.SubElement(feat_declarations, "SpotFeatures")
 44    for feat in SPOT_FEATS:
 45        ET.SubElement(spot_feats, "Feature", feat)
 46    edge_feats = ET.SubElement(feat_declarations, "EdgeFeatures")
 47    for feat in EDGE_FEATS:
 48        ET.SubElement(edge_feats, "Feature", feat)
 49    track_feats = ET.SubElement(feat_declarations, "TrackFeatures")
 50    for feat in TRACK_FEATS:
 51        ET.SubElement(track_feats, "Feature", feat)
 52    return feat_declarations
 53
 54
 55def build_spots_df(epic):
 56    """Build a DataFrame representing the spots table for TrackMate XML."""
 57    df_spots = pd.DataFrame(epic.tracking.track_data, columns=["label", "FRAME", "pos_x", "pos_y"])
 58    df_spots["ID"] = df_spots.index
 59    df_spots["name"] = df_spots.apply(lambda row: f"LABEL{row['label']}_FRAME{row['FRAME']}", axis=1)
 60    # Invert X and Y to match TrackMate convention.
 61    df_spots["POSITION_X"] = df_spots["pos_y"] * epic.epi_metadata.get("ScaleXY", 1)
 62    df_spots["POSITION_Y"] = df_spots["pos_x"] * epic.epi_metadata.get("ScaleXY", 1)
 63    df_spots["POSITION_Z"] = 0.0  # 2D data. 
 64    df_spots["POSITION_T"] = df_spots["FRAME"] * epic.epi_metadata.get("ScaleT", 1)
 65    df_spots["VISIBILITY"] = 1
 66    df_spots.drop(columns=["pos_x", "pos_y"], inplace=True)
 67
 68    return df_spots
 69
 70
 71def get_cell_contour(seg_array, label, frame):
 72    """Get the contours of the cell with the given label in the given frame."""
 73    spot_seg = seg_array[frame, :, :] == label
 74    # print(spot_seg.dtype)
 75    # Pad to avoid contours on the border (skimage.measure.find_contours)
 76    spot_seg = np.pad(spot_seg, pad_width=1, mode='constant', constant_values=0)
 77    #spot_seg_uint8 = spot_seg.astype(np.uint8)
 78    # print(spot_seg_uint8.dtype)
 79
 80    if np.sum(spot_seg) > 0:
 81        contours = find_contours(spot_seg, level=0.5)
 82        #contours, _ = cv2.findContours(spot_seg_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
 83        # print(type(contours))
 84        # if label == 125 and frame == 0:
 85        #     contours = contours[:-1]  # hack to remove spurious contour
 86
 87        # plt.show()
 88        # print(contours)
 89        assert len(contours) > 0, f"No contour found for label {label} in frame {frame}"
 90        # assert len(contours) < 2, f"More than one contour found for label {label} in frame {frame}"
 91        if len(contours) > 1:
 92             print(f"Warning, found two cells for label {label} at frame {frame}")
 93             print(f"Keep only the first one in the export")
 94        #if label==6:
 95        #     fig, ax = plt.subplots(1, 1)
 96        #     ax.imshow(seg_array[frame,:,:]>0)
 97             #ax.imshow(spot_seg, cmap="grey")
 98             # Plot each contour
 99        #     for contour in contours:
100        #         contour = contour.squeeze()
101        #         ax.plot(contour[:, 1], contour[:, 0], linewidth=1)
102
103        #     plt.show()
104
105        return np.flip(contours[0].squeeze(), axis=1)
106    return None
107
108
109def build_roi_contours(epic, df_spots):
110    """Build a mapping from spot ID to its ROI contour."""
111    roi_contours = {}
112    seg_layer = epic.seg
113    for _, spot in df_spots.iterrows():
114        contour = get_cell_contour(seg_layer, spot["label"], spot["FRAME"])
115        if contour is not None:
116            # Convert from pixels to space units.
117            contour = contour.astype(np.float64)  # in case the scale is a float
118            # Remove the padding
119            contour[:, 0] -= 1 
120            contour[:, 1] -= 1 
121            contour[:, 0] *= epic.epi_metadata.get("ScaleXY", 1)
122            contour[:, 1] *= epic.epi_metadata.get("ScaleXY", 1)
123            # Convert contour from absolute to relative coordinates (to the cell XY position).
124            contour[:, 0] -= spot["POSITION_X"]
125            contour[:, 1] -= spot["POSITION_Y"]
126            # Flatten contour to a 1D array as expected by TrackMate.
127            contour = contour.flatten()
128            roi_contours[spot["ID"]] = contour
129            # print(spot["ID"], spot["label"], spot["FRAME"], contour.shape)
130    return roi_contours
131
132
133def build_all_spots_tag(df_spots, roi_n_points):
134    """Build the AllSpots tag for TrackMate XML."""
135    all_spots = ET.Element("AllSpots", {"nspots": str(len(df_spots))})
136    frames = df_spots["FRAME"].unique()
137    # print(roi_n_points)
138    for frame in frames:
139        spots_in_frame = df_spots[df_spots["FRAME"] == frame]
140        frame_tag = ET.SubElement(all_spots, "SpotsInFrame", {"frame": str(frame)})
141        for _, spot in spots_in_frame.iterrows():
142            spot_attrib = {
143                "ID": str(spot["ID"]),
144                "name": spot["name"],
145                "EpiCure_label": str(spot["label"]),
146                "POSITION_X": str(spot["POSITION_X"]),
147                "POSITION_Y": str(spot["POSITION_Y"]),
148                "POSITION_Z": str(spot["POSITION_Z"]),
149                "POSITION_T": str(spot["POSITION_T"]),
150                "FRAME": str(spot["FRAME"]),
151                "VISIBILITY": str(spot["VISIBILITY"]),
152                "RADIUS": "-1",
153                "MANUAL_SPOT_COLOR": "0",  # TODO: use it to store group
154            }
155
156            # Add ROI contour if available.
157            if spot["ID"] in roi_n_points:
158                spot_attrib["ROI_N_POINTS"] = str(roi_n_points[spot["ID"]].shape[0] // 2)
159
160            spot_tag = ET.SubElement(frame_tag, "Spot", spot_attrib)
161            if spot["ID"] in roi_n_points:
162                spot_tag.text = " ".join(map(str, roi_n_points[spot["ID"]]))
163
164    return all_spots
165
166
167def create_label_to_track_mapping(divisions: Dict[int, List[int]], unique_labels: List[int]) -> Dict[int, int]:
168    """
169    Create a mapping from labels to track IDs using scipy's DisjointSet for efficient track grouping.
170
171    Args:
172        divisions: dict of {daughter_label: [mother_labels]} from epic.tracking.graph
173        unique_labels: list of unique labels present in the tracking data
174
175    Returns:
176        dict: {label: track_id} - mapping from each label to its track ID
177    """
178    if not divisions:
179        # No divisions - each unique label is its own track.
180        return {label: label for label in unique_labels}
181
182    ds = DisjointSet(unique_labels)
183
184    # Union connected labels based on mother-daughter relationships.
185    for daughter, mothers in divisions.items():
186        if daughter not in unique_labels:  # weirdly, this can happen
187            continue
188        for mother in mothers:
189            if mother in unique_labels:
190                ds.merge(daughter, mother)
191
192    # A connected component is a TrackMate track. We use the root as track ID.
193    # Create a mapping from label to track_id (root).
194    label_to_track_id = {}
195    for label in unique_labels:
196        root = ds[label]
197        label_to_track_id[label] = root
198
199    return label_to_track_id
200
201
202def build_all_tracks_data(divisions, df_spots):
203    """"""
204    print(f"Divisions: {divisions}")
205    for mothers in divisions.values():
206        if len(mothers) > 1:
207            print("FUSION")
208
209    # Generate and assign TRACK_IDs.
210    labels = df_spots["label"].unique()
211    label_to_track_id = create_label_to_track_mapping(divisions, labels)
212    df_spots["TRACK_ID"] = df_spots["label"].map(label_to_track_id)
213
214    # Division edges: for each daughter-mother pair, create an edge.
215    edges_data = [{"daughter": daughter, "mother": mother} for daughter, mothers in divisions.items() for mother in mothers]
216    df_edges = pd.DataFrame(edges_data)
217    # Labels stay the same until there is a division. But spots ID are unique.
218    # It means that in df_spots, labels appears multiple times. Because of this
219    # we cannot easily map between df_spots and df_edges. So we create intermediary
220    # columns to ease the mapping.
221    df_spots["first_frame"] = df_spots.groupby("label")["FRAME"].transform("min")
222    df_spots["last_frame"] = df_spots.groupby("label")["FRAME"].transform("max")
223    # A daughter is at the first frame of its label, a mother at the last frame of its label.
224    df_spots["daughter"] = df_spots["first_frame"] == df_spots["FRAME"]
225    df_spots["mother"] = df_spots["last_frame"] == df_spots["FRAME"]
226    df_spots.drop(columns=["first_frame", "last_frame"], inplace=True)
227    # Now we can map between df_spots and df_edges.
228    # The SPOT_SOURCE_ID is the spot ID of the matching label that is a mother,
229    # and the SPOT_TARGET_ID is the spot ID of the matching label that is a daughter.
230    df_edges["SPOT_SOURCE_ID"] = df_edges["mother"].map(df_spots[df_spots["mother"]].set_index("label")["ID"])
231    df_edges["SPOT_TARGET_ID"] = df_edges["daughter"].map(df_spots[df_spots["daughter"]].set_index("label")["ID"])
232    df_spots.drop(columns=["daughter", "mother"], inplace=True)
233
234    # Add TRACK_ID to division edges (needed for XML).
235    if not df_edges.empty:
236        df_edges["TRACK_ID"] = df_edges["mother"].map(label_to_track_id)
237        df_edges.drop(columns=["daughter", "mother"], inplace=True)
238
239    # Non-division edges: for each label, connect consecutive spots within that label.
240    non_division_edges = []
241    for label in df_spots["label"].unique():
242        label_spots = df_spots[df_spots["label"] == label].sort_values("FRAME")
243        if len(label_spots) > 1:
244            track_id = label_spots.iloc[0]["TRACK_ID"]
245            for i in range(len(label_spots) - 1):
246                current_spot = label_spots.iloc[i]
247                next_spot = label_spots.iloc[i + 1]
248                non_division_edges.append({"SPOT_SOURCE_ID": current_spot["ID"], "SPOT_TARGET_ID": next_spot["ID"], "TRACK_ID": track_id})
249
250    # Combine division and non-division edges.
251    df_non_division_edges = pd.DataFrame(non_division_edges)
252    if not df_edges.empty and not df_non_division_edges.empty:
253        # Make sure both dataframes have the same columns.
254        df_edges = df_edges[["SPOT_SOURCE_ID", "SPOT_TARGET_ID", "TRACK_ID"]]
255        df_edges = pd.concat([df_edges, df_non_division_edges], ignore_index=True)
256    elif not df_non_division_edges.empty:
257        df_edges = df_non_division_edges
258
259    # Final cleanup and type conversion.
260    if not df_edges.empty:
261        # We can have NaN if a label has no mother (appears at first frame)
262        # or no daughter (disappears at last frame).
263        df_edges.dropna(inplace=True)
264        # Convert to int in case of NaN.
265        df_edges["SPOT_SOURCE_ID"] = df_edges["SPOT_SOURCE_ID"].astype(int)
266        df_edges["SPOT_TARGET_ID"] = df_edges["SPOT_TARGET_ID"].astype(int)
267        df_edges["TRACK_ID"] = df_edges["TRACK_ID"].astype(int)
268
269    return df_edges
270
271
272def build_all_tracks_tag(df_edges):
273    """Build the AllTracks tag for TrackMate XML."""
274    all_tracks = ET.Element("AllTracks")
275    track_ids = sorted(df_edges["TRACK_ID"].unique())
276    track_id_to_index = {track_id: index for index, track_id in enumerate(track_ids)}
277
278    for track_id in track_ids:
279        track = ET.SubElement(all_tracks, "Track")
280        track.set("name", f"Track_{track_id}")
281        track.set("TRACK_ID", str(track_id))
282        track.set("TRACK_INDEX", str(track_id_to_index[track_id]))
283
284        track_edges = df_edges[df_edges["TRACK_ID"] == track_id]
285        for _, edge in track_edges.iterrows():
286            edge_attrib = {
287                "SPOT_SOURCE_ID": str(edge["SPOT_SOURCE_ID"]),
288                "SPOT_TARGET_ID": str(edge["SPOT_TARGET_ID"]),
289            }
290            ET.SubElement(track, "Edge", edge_attrib)
291
292    return all_tracks
293
294
295def build_filtered_tracks_tag(track_ids):
296    """Build the FilteredTracks tag for TrackMate XML."""
297    filtered_tracks = ET.Element("FilteredTracks")
298    for track_id in track_ids:
299        ET.SubElement(filtered_tracks, "TrackID", {"TRACK_ID": str(track_id)})
300    return filtered_tracks
301
302
303def build_model_tag(epic):
304    """Build the Model tag for TrackMate XML."""
305    model = ET.Element("Model")
306    model.set("spatialunits", epic.epi_metadata.get("UnitXY", "pixel"))
307    model.set("timeunits", epic.epi_metadata.get("UnitT", "frame"))
308    model.append(build_feat_declaration_tag())
309
310    print("Tracked?", epic.tracked)
311    df_spots = build_spots_df(epic)
312    cell_contours = build_roi_contours(epic, df_spots)
313    model.append(build_all_spots_tag(df_spots, cell_contours))
314    divisions = epic.tracking.graph  # dict of {daughter: mothers}
315    if divisions:
316        df_edges = build_all_tracks_data(divisions, df_spots)
317        model.append(build_all_tracks_tag(df_edges))
318        track_ids = sorted(df_edges["TRACK_ID"].unique())
319        model.append(build_filtered_tracks_tag(track_ids))
320    else:
321        model.append(ET.Element("AllTracks"))
322        model.append(ET.Element("FilteredTracks"))
323
324    return model
325
326
327def build_settings_tag(epic):
328    """Build the Settings tag for TrackMate XML."""
329    settings = ET.Element("Settings")
330    img_path = Path(epic.epi_metadata["MovieFile"])
331    ET.SubElement(
332        settings,
333        "ImageData",
334        {
335            "filename": img_path.name,
336            "folder": str(img_path.parent),  # TODO: missing / at the end compared to TM, is it an issue?
337            "width": str(epic.imgshape2D[1]),
338            "height": str(epic.imgshape2D[0]),
339            "nslices": "1",
340            "nframes": str(epic.nframes),
341            "pixelwidth": str(epic.epi_metadata.get("ScaleXY", 1)),
342            "pixelheight": str(epic.epi_metadata.get("ScaleXY", 1)),
343            "voxeldepth": "1",
344            "timeinterval": str(epic.epi_metadata.get("ScaleT", 1)),
345        },
346    )
347    ET.SubElement(
348        settings,
349        "BasicSettings",
350        {
351            "xstart": "0",
352            "xend": str(epic.imgshape2D[1] - 1),
353            "ystart": "0",
354            "yend": str(epic.imgshape2D[0] - 1),
355            "zstart": "0",
356            "zend": "0",
357            "tstart": "0",
358            "tend": str(epic.nframes - 1),
359        },
360    )
361    ET.SubElement(settings, "DetectorSettings", {"DETECTOR_NAME": "MANUAL_DETECTOR", "RADIUS": "5"})
362    ET.SubElement(settings, "InitialSpotFilter")
363    ET.SubElement(settings, "SpotFilterCollection")
364    ET.SubElement(settings, "TrackerSettings", {"TRACKER_NAME": "MANUAL_TRACKER"})
365    ET.SubElement(settings, "TrackFilterCollection")
366    return settings
367
368
369# def build_gui_state_tag():
370#     """Build the GUIState tag for TrackMate XML."""
371#     gui_state = ET.Element("GUIState")
372#     return gui_state
373
374
375def pretty_print_xml(element):
376    """Pretty print an XML element."""
377    rough_string = ET.tostring(element, encoding="utf-8")
378    parsed = minidom.parseString(rough_string)
379    return parsed.toprettyxml(indent="  ")
380
381
382def save_trackmate_xml(epic, outname):
383    """Save a TrackMate XML file."""
384    if epic.verbose == 3:
385        ut.show_info(f"ScaleXY: {epic.epi_metadata.get('ScaleXY')}")
386        ut.show_info(f"ScaleT: {epic.epi_metadata.get('ScaleT')}")
387        ut.show_info(f"imgshape2D: {epic.imgshape2D}")
388        ut.show_info(f"UnitXY: {epic.epi_metadata.get('UnitXY')}")
389        ut.show_info(f"UnitT: {epic.epi_metadata.get('UnitT')}")
390
391    root = ET.Element("TrackMate", {"version": "unknown"})
392    log = ET.SubElement(root, "Log")
393    now = datetime.now()
394    log.text = f"Created by EpiCure on {now.strftime('%Y-%m-%d %H:%M:%S')}"
395    model = build_model_tag(epic)
396    root.append(model)
397    settings = build_settings_tag(epic)
398    root.append(settings)
399    ET.SubElement(root, "GUIState", {"state": "ConfigureViews"})
400    ET.SubElement(root, "DisplaySettings")
401    # display_settings = ET.SubElement(root, "DisplaySettings")
402    # display_settings.text = "{'spotDisplayedAsRoi': true}"
403    # tree = ET.ElementTree(root)
404
405    pretty_xml = pretty_print_xml(root)
406    # TODO: check if ET.indent is better or more efficient than pretty_print_xml
407    # for Python 3.9+
408    # Pretty formatting - ET.indent is available from Python 3.9+
409    # try:
410    #     ET.indent(tree, space="  ", level=0)
411    # except AttributeError:
412    #     # Fallback for Python < 3.9
413    #     pass
414    with open(outname, "w", encoding="utf-8") as f:
415        f.write(pretty_xml)
SPOT_FEATS = [{'feature': 'POSITION_X', 'name': 'X', 'shortname': 'X', 'dimension': 'POSITION', 'isint': 'false'}, {'feature': 'POSITION_Y', 'name': 'Y', 'shortname': 'Y', 'dimension': 'POSITION', 'isint': 'false'}, {'feature': 'POSITION_Z', 'name': 'Z', 'shortname': 'Z', 'dimension': 'POSITION', 'isint': 'false'}, {'feature': 'POSITION_T', 'name': 'T', 'shortname': 'T', 'dimension': 'TIME', 'isint': 'false'}, {'feature': 'FRAME', 'name': 'Frame', 'shortname': 'Frame', 'dimension': 'NONE', 'isint': 'true'}, {'feature': 'VISIBILITY', 'name': 'Visibility', 'shortname': 'Visibility', 'dimension': 'NONE', 'isint': 'true'}, {'feature': 'RADIUS', 'name': 'Radius', 'shortname': 'R', 'dimension': 'LENGTH', 'isint': 'false'}, {'feature': 'MANUAL_SPOT_COLOR', 'name': 'Manual spot color', 'shortname': 'Spot color', 'dimension': 'NONE', 'isint': 'true'}]
EDGE_FEATS = [{'feature': 'SPOT_SOURCE_ID', 'name': 'Source spot ID', 'shortname': 'Source ID', 'dimension': 'NONE', 'isint': 'true'}, {'feature': 'SPOT_TARGET_ID', 'name': 'Target spot ID', 'shortname': 'Target ID', 'dimension': 'NONE', 'isint': 'true'}]
TRACK_FEATS = [{'feature': 'TRACK_INDEX', 'name': 'Track index', 'shortname': 'Index', 'dimension': 'NONE', 'isint': 'true'}, {'feature': 'TRACK_ID', 'name': 'Track ID', 'shortname': 'ID', 'dimension': 'NONE', 'isint': 'true'}]
def build_feat_declaration_tag():
41def build_feat_declaration_tag():
42    """Build the FeatureDeclarations tag for TrackMate XML."""
43    feat_declarations = ET.Element("FeatureDeclarations")
44    spot_feats = ET.SubElement(feat_declarations, "SpotFeatures")
45    for feat in SPOT_FEATS:
46        ET.SubElement(spot_feats, "Feature", feat)
47    edge_feats = ET.SubElement(feat_declarations, "EdgeFeatures")
48    for feat in EDGE_FEATS:
49        ET.SubElement(edge_feats, "Feature", feat)
50    track_feats = ET.SubElement(feat_declarations, "TrackFeatures")
51    for feat in TRACK_FEATS:
52        ET.SubElement(track_feats, "Feature", feat)
53    return feat_declarations

Build the FeatureDeclarations tag for TrackMate XML.

def build_spots_df(epic):
56def build_spots_df(epic):
57    """Build a DataFrame representing the spots table for TrackMate XML."""
58    df_spots = pd.DataFrame(epic.tracking.track_data, columns=["label", "FRAME", "pos_x", "pos_y"])
59    df_spots["ID"] = df_spots.index
60    df_spots["name"] = df_spots.apply(lambda row: f"LABEL{row['label']}_FRAME{row['FRAME']}", axis=1)
61    # Invert X and Y to match TrackMate convention.
62    df_spots["POSITION_X"] = df_spots["pos_y"] * epic.epi_metadata.get("ScaleXY", 1)
63    df_spots["POSITION_Y"] = df_spots["pos_x"] * epic.epi_metadata.get("ScaleXY", 1)
64    df_spots["POSITION_Z"] = 0.0  # 2D data. 
65    df_spots["POSITION_T"] = df_spots["FRAME"] * epic.epi_metadata.get("ScaleT", 1)
66    df_spots["VISIBILITY"] = 1
67    df_spots.drop(columns=["pos_x", "pos_y"], inplace=True)
68
69    return df_spots

Build a DataFrame representing the spots table for TrackMate XML.

def get_cell_contour(seg_array, label, frame):
 72def get_cell_contour(seg_array, label, frame):
 73    """Get the contours of the cell with the given label in the given frame."""
 74    spot_seg = seg_array[frame, :, :] == label
 75    # print(spot_seg.dtype)
 76    # Pad to avoid contours on the border (skimage.measure.find_contours)
 77    spot_seg = np.pad(spot_seg, pad_width=1, mode='constant', constant_values=0)
 78    #spot_seg_uint8 = spot_seg.astype(np.uint8)
 79    # print(spot_seg_uint8.dtype)
 80
 81    if np.sum(spot_seg) > 0:
 82        contours = find_contours(spot_seg, level=0.5)
 83        #contours, _ = cv2.findContours(spot_seg_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
 84        # print(type(contours))
 85        # if label == 125 and frame == 0:
 86        #     contours = contours[:-1]  # hack to remove spurious contour
 87
 88        # plt.show()
 89        # print(contours)
 90        assert len(contours) > 0, f"No contour found for label {label} in frame {frame}"
 91        # assert len(contours) < 2, f"More than one contour found for label {label} in frame {frame}"
 92        if len(contours) > 1:
 93             print(f"Warning, found two cells for label {label} at frame {frame}")
 94             print(f"Keep only the first one in the export")
 95        #if label==6:
 96        #     fig, ax = plt.subplots(1, 1)
 97        #     ax.imshow(seg_array[frame,:,:]>0)
 98             #ax.imshow(spot_seg, cmap="grey")
 99             # Plot each contour
100        #     for contour in contours:
101        #         contour = contour.squeeze()
102        #         ax.plot(contour[:, 1], contour[:, 0], linewidth=1)
103
104        #     plt.show()
105
106        return np.flip(contours[0].squeeze(), axis=1)
107    return None

Get the contours of the cell with the given label in the given frame.

def build_roi_contours(epic, df_spots):
110def build_roi_contours(epic, df_spots):
111    """Build a mapping from spot ID to its ROI contour."""
112    roi_contours = {}
113    seg_layer = epic.seg
114    for _, spot in df_spots.iterrows():
115        contour = get_cell_contour(seg_layer, spot["label"], spot["FRAME"])
116        if contour is not None:
117            # Convert from pixels to space units.
118            contour = contour.astype(np.float64)  # in case the scale is a float
119            # Remove the padding
120            contour[:, 0] -= 1 
121            contour[:, 1] -= 1 
122            contour[:, 0] *= epic.epi_metadata.get("ScaleXY", 1)
123            contour[:, 1] *= epic.epi_metadata.get("ScaleXY", 1)
124            # Convert contour from absolute to relative coordinates (to the cell XY position).
125            contour[:, 0] -= spot["POSITION_X"]
126            contour[:, 1] -= spot["POSITION_Y"]
127            # Flatten contour to a 1D array as expected by TrackMate.
128            contour = contour.flatten()
129            roi_contours[spot["ID"]] = contour
130            # print(spot["ID"], spot["label"], spot["FRAME"], contour.shape)
131    return roi_contours

Build a mapping from spot ID to its ROI contour.

def build_all_spots_tag(df_spots, roi_n_points):
134def build_all_spots_tag(df_spots, roi_n_points):
135    """Build the AllSpots tag for TrackMate XML."""
136    all_spots = ET.Element("AllSpots", {"nspots": str(len(df_spots))})
137    frames = df_spots["FRAME"].unique()
138    # print(roi_n_points)
139    for frame in frames:
140        spots_in_frame = df_spots[df_spots["FRAME"] == frame]
141        frame_tag = ET.SubElement(all_spots, "SpotsInFrame", {"frame": str(frame)})
142        for _, spot in spots_in_frame.iterrows():
143            spot_attrib = {
144                "ID": str(spot["ID"]),
145                "name": spot["name"],
146                "EpiCure_label": str(spot["label"]),
147                "POSITION_X": str(spot["POSITION_X"]),
148                "POSITION_Y": str(spot["POSITION_Y"]),
149                "POSITION_Z": str(spot["POSITION_Z"]),
150                "POSITION_T": str(spot["POSITION_T"]),
151                "FRAME": str(spot["FRAME"]),
152                "VISIBILITY": str(spot["VISIBILITY"]),
153                "RADIUS": "-1",
154                "MANUAL_SPOT_COLOR": "0",  # TODO: use it to store group
155            }
156
157            # Add ROI contour if available.
158            if spot["ID"] in roi_n_points:
159                spot_attrib["ROI_N_POINTS"] = str(roi_n_points[spot["ID"]].shape[0] // 2)
160
161            spot_tag = ET.SubElement(frame_tag, "Spot", spot_attrib)
162            if spot["ID"] in roi_n_points:
163                spot_tag.text = " ".join(map(str, roi_n_points[spot["ID"]]))
164
165    return all_spots

Build the AllSpots tag for TrackMate XML.

def create_label_to_track_mapping( divisions: Dict[int, List[int]], unique_labels: List[int]) -> Dict[int, int]:
168def create_label_to_track_mapping(divisions: Dict[int, List[int]], unique_labels: List[int]) -> Dict[int, int]:
169    """
170    Create a mapping from labels to track IDs using scipy's DisjointSet for efficient track grouping.
171
172    Args:
173        divisions: dict of {daughter_label: [mother_labels]} from epic.tracking.graph
174        unique_labels: list of unique labels present in the tracking data
175
176    Returns:
177        dict: {label: track_id} - mapping from each label to its track ID
178    """
179    if not divisions:
180        # No divisions - each unique label is its own track.
181        return {label: label for label in unique_labels}
182
183    ds = DisjointSet(unique_labels)
184
185    # Union connected labels based on mother-daughter relationships.
186    for daughter, mothers in divisions.items():
187        if daughter not in unique_labels:  # weirdly, this can happen
188            continue
189        for mother in mothers:
190            if mother in unique_labels:
191                ds.merge(daughter, mother)
192
193    # A connected component is a TrackMate track. We use the root as track ID.
194    # Create a mapping from label to track_id (root).
195    label_to_track_id = {}
196    for label in unique_labels:
197        root = ds[label]
198        label_to_track_id[label] = root
199
200    return label_to_track_id

Create a mapping from labels to track IDs using scipy's DisjointSet for efficient track grouping.

Args: divisions: dict of {daughter_label: [mother_labels]} from epic.tracking.graph unique_labels: list of unique labels present in the tracking data

Returns: dict: {label: track_id} - mapping from each label to its track ID

def build_all_tracks_data(divisions, df_spots):
203def build_all_tracks_data(divisions, df_spots):
204    """"""
205    print(f"Divisions: {divisions}")
206    for mothers in divisions.values():
207        if len(mothers) > 1:
208            print("FUSION")
209
210    # Generate and assign TRACK_IDs.
211    labels = df_spots["label"].unique()
212    label_to_track_id = create_label_to_track_mapping(divisions, labels)
213    df_spots["TRACK_ID"] = df_spots["label"].map(label_to_track_id)
214
215    # Division edges: for each daughter-mother pair, create an edge.
216    edges_data = [{"daughter": daughter, "mother": mother} for daughter, mothers in divisions.items() for mother in mothers]
217    df_edges = pd.DataFrame(edges_data)
218    # Labels stay the same until there is a division. But spots ID are unique.
219    # It means that in df_spots, labels appears multiple times. Because of this
220    # we cannot easily map between df_spots and df_edges. So we create intermediary
221    # columns to ease the mapping.
222    df_spots["first_frame"] = df_spots.groupby("label")["FRAME"].transform("min")
223    df_spots["last_frame"] = df_spots.groupby("label")["FRAME"].transform("max")
224    # A daughter is at the first frame of its label, a mother at the last frame of its label.
225    df_spots["daughter"] = df_spots["first_frame"] == df_spots["FRAME"]
226    df_spots["mother"] = df_spots["last_frame"] == df_spots["FRAME"]
227    df_spots.drop(columns=["first_frame", "last_frame"], inplace=True)
228    # Now we can map between df_spots and df_edges.
229    # The SPOT_SOURCE_ID is the spot ID of the matching label that is a mother,
230    # and the SPOT_TARGET_ID is the spot ID of the matching label that is a daughter.
231    df_edges["SPOT_SOURCE_ID"] = df_edges["mother"].map(df_spots[df_spots["mother"]].set_index("label")["ID"])
232    df_edges["SPOT_TARGET_ID"] = df_edges["daughter"].map(df_spots[df_spots["daughter"]].set_index("label")["ID"])
233    df_spots.drop(columns=["daughter", "mother"], inplace=True)
234
235    # Add TRACK_ID to division edges (needed for XML).
236    if not df_edges.empty:
237        df_edges["TRACK_ID"] = df_edges["mother"].map(label_to_track_id)
238        df_edges.drop(columns=["daughter", "mother"], inplace=True)
239
240    # Non-division edges: for each label, connect consecutive spots within that label.
241    non_division_edges = []
242    for label in df_spots["label"].unique():
243        label_spots = df_spots[df_spots["label"] == label].sort_values("FRAME")
244        if len(label_spots) > 1:
245            track_id = label_spots.iloc[0]["TRACK_ID"]
246            for i in range(len(label_spots) - 1):
247                current_spot = label_spots.iloc[i]
248                next_spot = label_spots.iloc[i + 1]
249                non_division_edges.append({"SPOT_SOURCE_ID": current_spot["ID"], "SPOT_TARGET_ID": next_spot["ID"], "TRACK_ID": track_id})
250
251    # Combine division and non-division edges.
252    df_non_division_edges = pd.DataFrame(non_division_edges)
253    if not df_edges.empty and not df_non_division_edges.empty:
254        # Make sure both dataframes have the same columns.
255        df_edges = df_edges[["SPOT_SOURCE_ID", "SPOT_TARGET_ID", "TRACK_ID"]]
256        df_edges = pd.concat([df_edges, df_non_division_edges], ignore_index=True)
257    elif not df_non_division_edges.empty:
258        df_edges = df_non_division_edges
259
260    # Final cleanup and type conversion.
261    if not df_edges.empty:
262        # We can have NaN if a label has no mother (appears at first frame)
263        # or no daughter (disappears at last frame).
264        df_edges.dropna(inplace=True)
265        # Convert to int in case of NaN.
266        df_edges["SPOT_SOURCE_ID"] = df_edges["SPOT_SOURCE_ID"].astype(int)
267        df_edges["SPOT_TARGET_ID"] = df_edges["SPOT_TARGET_ID"].astype(int)
268        df_edges["TRACK_ID"] = df_edges["TRACK_ID"].astype(int)
269
270    return df_edges
def build_all_tracks_tag(df_edges):
273def build_all_tracks_tag(df_edges):
274    """Build the AllTracks tag for TrackMate XML."""
275    all_tracks = ET.Element("AllTracks")
276    track_ids = sorted(df_edges["TRACK_ID"].unique())
277    track_id_to_index = {track_id: index for index, track_id in enumerate(track_ids)}
278
279    for track_id in track_ids:
280        track = ET.SubElement(all_tracks, "Track")
281        track.set("name", f"Track_{track_id}")
282        track.set("TRACK_ID", str(track_id))
283        track.set("TRACK_INDEX", str(track_id_to_index[track_id]))
284
285        track_edges = df_edges[df_edges["TRACK_ID"] == track_id]
286        for _, edge in track_edges.iterrows():
287            edge_attrib = {
288                "SPOT_SOURCE_ID": str(edge["SPOT_SOURCE_ID"]),
289                "SPOT_TARGET_ID": str(edge["SPOT_TARGET_ID"]),
290            }
291            ET.SubElement(track, "Edge", edge_attrib)
292
293    return all_tracks

Build the AllTracks tag for TrackMate XML.

def build_filtered_tracks_tag(track_ids):
296def build_filtered_tracks_tag(track_ids):
297    """Build the FilteredTracks tag for TrackMate XML."""
298    filtered_tracks = ET.Element("FilteredTracks")
299    for track_id in track_ids:
300        ET.SubElement(filtered_tracks, "TrackID", {"TRACK_ID": str(track_id)})
301    return filtered_tracks

Build the FilteredTracks tag for TrackMate XML.

def build_model_tag(epic):
304def build_model_tag(epic):
305    """Build the Model tag for TrackMate XML."""
306    model = ET.Element("Model")
307    model.set("spatialunits", epic.epi_metadata.get("UnitXY", "pixel"))
308    model.set("timeunits", epic.epi_metadata.get("UnitT", "frame"))
309    model.append(build_feat_declaration_tag())
310
311    print("Tracked?", epic.tracked)
312    df_spots = build_spots_df(epic)
313    cell_contours = build_roi_contours(epic, df_spots)
314    model.append(build_all_spots_tag(df_spots, cell_contours))
315    divisions = epic.tracking.graph  # dict of {daughter: mothers}
316    if divisions:
317        df_edges = build_all_tracks_data(divisions, df_spots)
318        model.append(build_all_tracks_tag(df_edges))
319        track_ids = sorted(df_edges["TRACK_ID"].unique())
320        model.append(build_filtered_tracks_tag(track_ids))
321    else:
322        model.append(ET.Element("AllTracks"))
323        model.append(ET.Element("FilteredTracks"))
324
325    return model

Build the Model tag for TrackMate XML.

def build_settings_tag(epic):
328def build_settings_tag(epic):
329    """Build the Settings tag for TrackMate XML."""
330    settings = ET.Element("Settings")
331    img_path = Path(epic.epi_metadata["MovieFile"])
332    ET.SubElement(
333        settings,
334        "ImageData",
335        {
336            "filename": img_path.name,
337            "folder": str(img_path.parent),  # TODO: missing / at the end compared to TM, is it an issue?
338            "width": str(epic.imgshape2D[1]),
339            "height": str(epic.imgshape2D[0]),
340            "nslices": "1",
341            "nframes": str(epic.nframes),
342            "pixelwidth": str(epic.epi_metadata.get("ScaleXY", 1)),
343            "pixelheight": str(epic.epi_metadata.get("ScaleXY", 1)),
344            "voxeldepth": "1",
345            "timeinterval": str(epic.epi_metadata.get("ScaleT", 1)),
346        },
347    )
348    ET.SubElement(
349        settings,
350        "BasicSettings",
351        {
352            "xstart": "0",
353            "xend": str(epic.imgshape2D[1] - 1),
354            "ystart": "0",
355            "yend": str(epic.imgshape2D[0] - 1),
356            "zstart": "0",
357            "zend": "0",
358            "tstart": "0",
359            "tend": str(epic.nframes - 1),
360        },
361    )
362    ET.SubElement(settings, "DetectorSettings", {"DETECTOR_NAME": "MANUAL_DETECTOR", "RADIUS": "5"})
363    ET.SubElement(settings, "InitialSpotFilter")
364    ET.SubElement(settings, "SpotFilterCollection")
365    ET.SubElement(settings, "TrackerSettings", {"TRACKER_NAME": "MANUAL_TRACKER"})
366    ET.SubElement(settings, "TrackFilterCollection")
367    return settings

Build the Settings tag for TrackMate XML.

def pretty_print_xml(element):
376def pretty_print_xml(element):
377    """Pretty print an XML element."""
378    rough_string = ET.tostring(element, encoding="utf-8")
379    parsed = minidom.parseString(rough_string)
380    return parsed.toprettyxml(indent="  ")

Pretty print an XML element.

def save_trackmate_xml(epic, outname):
383def save_trackmate_xml(epic, outname):
384    """Save a TrackMate XML file."""
385    if epic.verbose == 3:
386        ut.show_info(f"ScaleXY: {epic.epi_metadata.get('ScaleXY')}")
387        ut.show_info(f"ScaleT: {epic.epi_metadata.get('ScaleT')}")
388        ut.show_info(f"imgshape2D: {epic.imgshape2D}")
389        ut.show_info(f"UnitXY: {epic.epi_metadata.get('UnitXY')}")
390        ut.show_info(f"UnitT: {epic.epi_metadata.get('UnitT')}")
391
392    root = ET.Element("TrackMate", {"version": "unknown"})
393    log = ET.SubElement(root, "Log")
394    now = datetime.now()
395    log.text = f"Created by EpiCure on {now.strftime('%Y-%m-%d %H:%M:%S')}"
396    model = build_model_tag(epic)
397    root.append(model)
398    settings = build_settings_tag(epic)
399    root.append(settings)
400    ET.SubElement(root, "GUIState", {"state": "ConfigureViews"})
401    ET.SubElement(root, "DisplaySettings")
402    # display_settings = ET.SubElement(root, "DisplaySettings")
403    # display_settings.text = "{'spotDisplayedAsRoi': true}"
404    # tree = ET.ElementTree(root)
405
406    pretty_xml = pretty_print_xml(root)
407    # TODO: check if ET.indent is better or more efficient than pretty_print_xml
408    # for Python 3.9+
409    # Pretty formatting - ET.indent is available from Python 3.9+
410    # try:
411    #     ET.indent(tree, space="  ", level=0)
412    # except AttributeError:
413    #     # Fallback for Python < 3.9
414    #     pass
415    with open(outname, "w", encoding="utf-8") as f:
416        f.write(pretty_xml)

Save a TrackMate XML file.