epicure.geff_import

  1import os
  2
  3import geff
  4import networkx as nx
  5import numpy as np
  6from zarr.storage import StoreLike
  7
  8import epicure.Utils as ut
  9
 10
 11def _check_preconditions(graph: nx.Graph, metadata: geff.GeffMetadata | None) -> None:
 12    """
 13    Check that the graph meets the preconditions for import: directed graph and no merging cells.
 14
 15    Args:
 16        graph (nx.Graph): The GEFF graph to check.
 17        metadata (geff.GeffMetadata | None): The GEFF metadata.
 18    """
 19
 20    if metadata is None:
 21        if not isinstance(graph, nx.DiGraph):
 22            ut.show_error(f"The GEFF graph must be directed. Found type: {type(graph)}.")
 23    else:
 24        if not metadata.directed:
 25            ut.show_error(
 26                "The GEFF graph must be directed. Metadata indicates an undirected graph."
 27            )
 28
 29    assert isinstance(graph, nx.DiGraph), (
 30        f"The GEFF graph must be directed. Found type: {type(graph)}."
 31    )
 32    fusions = [n for n in graph.nodes() if graph.in_degree(n) > 1]
 33    if len(fusions) > 0:
 34        ut.show_warning(
 35            f"Merging cells detected (nodes: {fusions}). EpiCure behavior may be affected."
 36        )
 37
 38
 39def _identify_labels_path(metadata: geff.GeffMetadata) -> tuple[str | None, str | None]:
 40    """
 41    Identify the label key and path to the labels image from GEFF metadata.
 42
 43    Args:
 44        metadata (geff.GeffMetadata): The GEFF metadata.
 45
 46    Returns:
 47        tuple[str | None, str | None]: A tuple containing the label key
 48            and the path to the labels image if present, None otherwise.
 49    """
 50    if metadata.related_objects is None:
 51        return None, None
 52
 53    related_objects = [obj for obj in metadata.related_objects if obj.type == "labels"]
 54    if len(related_objects) == 0:
 55        return None, None
 56    elif len(related_objects) > 1:
 57        ut.show_warning(
 58            "Multiple related objects of type 'labels' found in GEFF metadata. "
 59            "Cannot determine which one to use. No labels image will be imported."
 60        )
 61        return None, None
 62    else:
 63        obj = related_objects[0]
 64        ut.show_debug(
 65            f"Labels image path identified from GEFF metadata: '{obj.path}'.",
 66        )
 67        if obj.label_prop is not None:
 68            ut.show_debug(
 69                f"Label property for labels image identified from GEFF metadata: '{obj.label_prop}'.",
 70            )
 71            return obj.label_prop, obj.path
 72        else:
 73            ut.show_warning(
 74                "Label property for labels image not specified in GEFF metadata. "
 75                "Falling back to 'label'.",
 76            )
 77            return "label", obj.path
 78
 79
 80def _identify_prop(
 81    geff_md: geff.GeffMetadata | None,
 82    geff_graph: nx.DiGraph,
 83    prop_name: str,
 84) -> str | None:
 85    """
 86    Identify the actual name of a property if it exists, given a property name to look for.
 87
 88    Args:
 89        geff_md (geff.GeffMetadata | None): The GEFF metadata.
 90        geff_graph (nx.DiGraph): The GEFF graph.
 91        prop_name (str): The name of the property to check.
 92
 93    Returns:
 94        str | None: The actual name of the property if present in all graph nodes, None otherwise.
 95    """
 96    if geff_md is not None and geff_md.node_props_metadata:
 97        for prop_id in geff_md.node_props_metadata.keys():
 98            if prop_id.lower() == prop_name.lower():
 99                if all(prop_id in geff_graph.nodes[node] for node in geff_graph.nodes):
100                    ut.show_debug(
101                        f"Property '{prop_id}' found in GEFF metadata and present in all graph nodes.",
102                    )
103                    return prop_id
104                else:
105                    ut.show_debug(
106                        f"Property '{prop_id}' found in GEFF metadata but not present in all graph nodes.",
107                    )
108
109    return None
110
111
112def _identify_time_axis(
113    geff_md: geff.GeffMetadata | None,
114    geff_graph: nx.DiGraph,
115) -> str:
116    """
117    Identify the time axis from GEFF metadata.
118
119    The function will try to infer it first from the display hints, then from the axes.
120    In case the function fallbacks to the GEFF axes, it will take time axes in order
121    and check if the corresponding key exists in the graph. The first one to be found
122    will be returned as the time axis.
123
124    Args:
125        geff_md (geff.GeffMetadata | None): The GEFF metadata.
126        geff_graph (nx.DiGraph): The GEFF graph.
127
128    Returns:
129        str: The identified time axis.
130    """
131    # Check for time in display hints.
132    time_key = None
133    hints = geff_md.display_hints if geff_md is not None else None
134    if hints is not None:
135        time_key = getattr(hints, "display_time", None)
136        if time_key is not None:
137            if all(time_key in geff_graph.nodes[node] for node in geff_graph.nodes):
138                ut.show_debug(
139                    f"Valid time axis inferred from GEFF display hints: '{time_key}'.",
140                )
141            else:
142                ut.show_debug(
143                    f"Time axis '{time_key}' inferred from GEFF display hints is not present "
144                    "in all the graph nodes. Falling back to GEFF axes to identify it.",
145                )
146                time_key = None
147
148    # Fallback to GEFF axes.
149    axes = geff_md.axes if geff_md is not None else None
150    if time_key is None and axes is not None:
151        time_axes = [axis for axis in axes if axis.type == "time"]
152        for axis in time_axes:
153            if axis.name is not None and all(
154                axis.name in geff_graph.nodes[node] for node in geff_graph.nodes
155            ):
156                time_key = axis.name
157                ut.show_debug(
158                    f"Valid time axis inferred from GEFF axes: '{time_key}'.",
159                )
160                break
161
162    if time_key is None:
163        ut.show_error(
164            "No valid time axis found. Please ensure that the GEFF file contains "
165            "a time axis or a time display hint."
166        )
167
168    return time_key
169
170
171def _identify_space_axes(
172    geff_md: geff.GeffMetadata | None,
173    geff_graph: nx.DiGraph,
174) -> tuple[str, str]:
175    """
176    Identify the space axes (x, y) from GEFF metadata.
177
178    The function will try to infer them from the GEFF metadata,
179    first from the display hints, then from the axes.
180
181    Args:
182        geff_md (geff.GeffMetadata | None): The GEFF metadata.
183        geff_graph (nx.DiGraph): The GEFF graph.
184
185    Returns:
186        tuple[str, str]: The identified space axes.
187    """
188    space_keys = [None, None]
189
190    # Check for space in display hints.
191    hints = geff_md.display_hints if geff_md is not None else None
192    hint_fields = ["display_horizontal", "display_vertical"]
193    for i, hint_field in enumerate(hint_fields):
194        if hints is not None:
195            space_key = getattr(hints, hint_field, None)
196            if space_key is not None:
197                if all(space_key in geff_graph.nodes[node] for node in geff_graph.nodes):
198                    ut.show_debug(
199                        f"Valid space axis inferred from GEFF display hints: '{space_key}'.",
200                    )
201                else:
202                    ut.show_debug(
203                        f"Space axis '{space_key}' inferred from GEFF display hints is not present "
204                        "in all the graph nodes. Falling back to GEFF axes to identify it.",
205                    )
206                    space_key = None
207            space_keys[i] = space_key
208
209    # Fallback to GEFF axes: space axes are consumed in order.
210    axes = geff_md.axes if geff_md is not None else None
211    if axes is not None:
212        space_axes = iter(axis for axis in axes if axis.type == "space")
213        for i, key in enumerate(space_keys):
214            if key is None:
215                for axis in space_axes:
216                    if axis.name is not None and all(
217                        axis.name in geff_graph.nodes[node] for node in geff_graph.nodes
218                    ):
219                        space_keys[i] = axis.name
220                        ut.show_debug(
221                            f"Valid space axis inferred from GEFF axes: '{space_keys[i]}'.",
222                        )
223                        break
224
225    if space_keys[0] is None or space_keys[1] is None:
226        ut.show_error(
227            "No valid space axes found. Please ensure that the GEFF file contains "
228            "space axes or space display hints."
229        )
230
231    return space_keys[0], space_keys[1]
232
233
234def _generate_label(geff_graph: nx.DiGraph) -> None:
235    """
236    Add a 'label' node attribute to each node in the graph.
237    Each linear path (unbranched segment) receives a unique label starting from 0.
238
239    Args:
240        geff_graph (nx.DiGraph): The graph to label. Modified in-place.
241
242    Modifies:
243        geff_graph (nx.DiGraph): Adds a 'label' attribute to each node.
244    """
245    labeled_nodes = set()
246    label_counter = 0
247
248    for start_node in geff_graph.nodes():
249        if start_node in labeled_nodes:
250            continue
251
252        # Start a new linear path.
253        current = start_node
254        path_nodes = []
255
256        # Follow the chain as long as we have a single successor
257        # with a single predecessor (linear continuation).
258        while current is not None and current not in labeled_nodes:
259            path_nodes.append(current)
260            labeled_nodes.add(current)
261
262            successors = list(geff_graph.successors(current))
263
264            if len(successors) == 1:
265                next_node = successors[0]
266                predecessors = list(geff_graph.predecessors(next_node))
267                # Continue only if next node has exactly one predecessor.
268                if len(predecessors) == 1:
269                    current = next_node
270                else:
271                    current = None  # branching point ahead
272            else:
273                current = None  # end of linear path
274
275        # Assign label to all nodes in this path.
276        for node in path_nodes:
277            geff_graph.nodes[node]["label"] = label_counter
278
279        label_counter += 1
280
281
282def _build_positions_array(
283    geff_graph: nx.DiGraph,
284    label_key: str,
285    time_key: str,
286    x_key: str,
287    y_key: str,
288) -> np.ndarray:
289    """
290    Build the positions array from the GEFF graph.
291
292    Args:
293        geff_graph (nx.DiGraph): The GEFF graph containing the nodes with their attributes.
294        label_key (str): The key for the label attribute in the graph nodes.
295        time_key (str): The key for the time/frame attribute in the graph nodes.
296        x_key (str): The key for the x coordinate attribute in the graph nodes.
297        y_key (str): The key for the y coordinate attribute in the graph nodes.
298
299    Returns:
300        np.ndarray: The filled positions array with columns [label, time, y, x].
301    """
302    positions = np.empty((len(geff_graph), 4), dtype=np.int32)
303    for i, node in enumerate(geff_graph.nodes()):
304        node_data = geff_graph.nodes[node]
305        positions[i, 0] = node_data[label_key]
306        positions[i, 1] = node_data[time_key]
307        positions[i, 2] = node_data[y_key]
308        positions[i, 3] = node_data[x_key]
309    # TODO Check it s ok not to sort position by time
310    return positions
311
312
313def _build_tracks_dict(geff_graph: nx.DiGraph, label_key: str) -> dict[int, list[int]]:
314    """
315    Build the tracks dictionary from the GEFF graph ({daughter_label: [mother_labels]}).
316
317    Args:
318        geff_graph (nx.DiGraph): The GEFF graph containing the nodes and edges.
319        label_key (str): The key for the label attribute in the graph nodes.
320
321    Returns:
322        dict[int, list[int]]: A dictionary mapping each daughter label to a list of its mother labels.
323    """
324    tracks: dict[int, list[int]] = {}
325    divisions = [n for n in geff_graph.nodes() if geff_graph.out_degree(n) > 1]
326    for div in divisions:
327        for daughter in geff_graph.successors(div):
328            mother_label = geff_graph.nodes[div][label_key]
329            daughter_label = geff_graph.nodes[daughter][label_key]
330            if daughter_label not in tracks:
331                tracks[daughter_label] = []
332            tracks[daughter_label].append(mother_label)
333    return tracks
334
335
336def _get_metadata(
337    metadata: geff.GeffMetadata, time_key: str, x_key: str, y_key: str
338) -> dict[str, str]:
339    """
340    Extract metadata from GEFF metadata object.
341
342    In EpiCure, time and space metadata are stored as UnitT, ScaleT, UnitXY, ScaleXY,
343    with UnitXY and UnitT being expressed in real world units. However, the data
344    in the GEFF graph AND in EpiCure are expressed in pixel and frame units.
345
346    Args:
347        metadata (geff.GeffMetadata): The GEFF metadata.
348        time_key (str): The key for the time/frame attribute.
349        x_key (str): The key for the x coordinate attribute.
350        y_key (str): The key for the y coordinate attribute.
351
352    Returns:
353        dict[str, str]: A dictionary of metadata key-value pairs.
354    """
355    md = {}
356    x_axis = None
357    y_axis = None
358    if metadata.axes is not None:
359        for axis in metadata.axes:
360            if axis.name == time_key:
361                md["UnitT"] = axis.scaled_unit
362                md["ScaleT"] = axis.scale
363            elif axis.name == x_key:
364                x_axis = axis
365            elif axis.name == y_key:
366                y_axis = axis
367
368    if x_axis is not None and y_axis is not None:
369        if x_axis.scaled_unit == y_axis.scaled_unit:
370            md["UnitXY"] = x_axis.scaled_unit
371        else:
372            ut.show_warning(
373                f"Different units for x and y axes: '{x_axis.scaled_unit}' and '{y_axis.scaled_unit}'. "
374                "UnitXY metadata will not be set."
375            )
376        if x_axis.scale == y_axis.scale:
377            md["ScaleXY"] = x_axis.scale
378        else:
379            ut.show_warning(
380                f"Different scales for x and y axes: '{x_axis.scale}' and '{y_axis.scale}'. "
381                "ScaleXY metadata will not be set."
382            )
383
384    return md
385
386
387def import_geff(
388    geff_path: StoreLike,
389) -> tuple[np.ndarray, dict[int, list[int]], dict[str, str], str | None]:
390    """
391    Import a GEFF file.
392
393    Args:
394        geff_path (StoreLike): The path to the GEFF file to import.
395
396    Returns:
397        tuple[np.ndarray, dict[int, list[int]], dict[str, str], str | None]: A tuple containing:
398            - A positions array with columns [label, time, y, x].
399            - A tracks dictionary mapping each daughter label to a list of its mother labels.
400            - A dictionary of metadata key-value pairs.
401            - The path to the labels image array if present, None otherwise.
402    """
403    geff_graph, geff_md = geff.read(geff_path, structure_validation=True)
404
405    _check_preconditions(geff_graph, geff_md)
406
407    if geff_md is not None:
408        label_key, labels_path = _identify_labels_path(geff_md)
409    else:
410        label_key, labels_path = None, None
411    # Even if we have a label key from the related objects, we need to check
412    # that it's actually present in the graph nodes.
413    label_key = _identify_prop(geff_md, geff_graph, label_key)
414    if label_key is None:
415        label_key = "label"
416        _generate_label(geff_graph)
417        ut.show_debug("Node labels generated and assigned to 'label'.")
418    else:
419        ut.show_debug(f"Identified label key: '{label_key}'.")
420
421    time_key = _identify_prop(geff_md, geff_graph, "frame")
422    if time_key is None:
423        ut.show_debug(
424            "No frame-like property identified in GEFF metadata. "
425            "Attempting to identify time axis from display hints or axes.",
426        )
427        time_key = _identify_time_axis(geff_md, geff_graph)
428
429    x_key = _identify_prop(geff_md, geff_graph, "x")
430    y_key = _identify_prop(geff_md, geff_graph, "y")
431    if x_key is None or y_key is None:
432        ut.show_debug(
433            "No x/y properties identified in GEFF metadata. "
434            "Attempting to identify space axes from display hints or axes.",
435        )
436    x_key, y_key = _identify_space_axes(geff_md, geff_graph)
437    ut.show_debug(f"Identified axes: '{time_key}', x: '{x_key}', y: '{y_key}'.")
438
439    positions = _build_positions_array(geff_graph, label_key, time_key, x_key, y_key)
440    tracks = _build_tracks_dict(geff_graph, label_key)
441
442    if geff_md is not None:
443        metadata = _get_metadata(geff_md, time_key, x_key, y_key)
444    else:
445        metadata = {}
446
447    ## labels path is a relative path (relative to GEFF). Convert it to absolute path
448    abs_path = os.path.join(geff_path, labels_path)
449    abs_path = os.path.abspath(abs_path)
450    return positions, tracks, metadata, abs_path
def import_geff( geff_path: Union[zarr._storage.store.BaseStore, MutableMapping]) -> tuple[numpy.ndarray, dict[int, list[int]], dict[str, str], str | None]:
388def import_geff(
389    geff_path: StoreLike,
390) -> tuple[np.ndarray, dict[int, list[int]], dict[str, str], str | None]:
391    """
392    Import a GEFF file.
393
394    Args:
395        geff_path (StoreLike): The path to the GEFF file to import.
396
397    Returns:
398        tuple[np.ndarray, dict[int, list[int]], dict[str, str], str | None]: A tuple containing:
399            - A positions array with columns [label, time, y, x].
400            - A tracks dictionary mapping each daughter label to a list of its mother labels.
401            - A dictionary of metadata key-value pairs.
402            - The path to the labels image array if present, None otherwise.
403    """
404    geff_graph, geff_md = geff.read(geff_path, structure_validation=True)
405
406    _check_preconditions(geff_graph, geff_md)
407
408    if geff_md is not None:
409        label_key, labels_path = _identify_labels_path(geff_md)
410    else:
411        label_key, labels_path = None, None
412    # Even if we have a label key from the related objects, we need to check
413    # that it's actually present in the graph nodes.
414    label_key = _identify_prop(geff_md, geff_graph, label_key)
415    if label_key is None:
416        label_key = "label"
417        _generate_label(geff_graph)
418        ut.show_debug("Node labels generated and assigned to 'label'.")
419    else:
420        ut.show_debug(f"Identified label key: '{label_key}'.")
421
422    time_key = _identify_prop(geff_md, geff_graph, "frame")
423    if time_key is None:
424        ut.show_debug(
425            "No frame-like property identified in GEFF metadata. "
426            "Attempting to identify time axis from display hints or axes.",
427        )
428        time_key = _identify_time_axis(geff_md, geff_graph)
429
430    x_key = _identify_prop(geff_md, geff_graph, "x")
431    y_key = _identify_prop(geff_md, geff_graph, "y")
432    if x_key is None or y_key is None:
433        ut.show_debug(
434            "No x/y properties identified in GEFF metadata. "
435            "Attempting to identify space axes from display hints or axes.",
436        )
437    x_key, y_key = _identify_space_axes(geff_md, geff_graph)
438    ut.show_debug(f"Identified axes: '{time_key}', x: '{x_key}', y: '{y_key}'.")
439
440    positions = _build_positions_array(geff_graph, label_key, time_key, x_key, y_key)
441    tracks = _build_tracks_dict(geff_graph, label_key)
442
443    if geff_md is not None:
444        metadata = _get_metadata(geff_md, time_key, x_key, y_key)
445    else:
446        metadata = {}
447
448    ## labels path is a relative path (relative to GEFF). Convert it to absolute path
449    abs_path = os.path.join(geff_path, labels_path)
450    abs_path = os.path.abspath(abs_path)
451    return positions, tracks, metadata, abs_path

Import a GEFF file.

Args: geff_path (StoreLike): The path to the GEFF file to import.

Returns: tuple[np.ndarray, dict[int, list[int]], dict[str, str], str | None]: A tuple containing: - A positions array with columns [label, time, y, x]. - A tracks dictionary mapping each daughter label to a list of its mother labels. - A dictionary of metadata key-value pairs. - The path to the labels image array if present, None otherwise.