Source code for traccuracy.metrics._complete_tracks_by_length

from __future__ import annotations

import warnings
from typing import TYPE_CHECKING

import numpy as np

from traccuracy.track_errors._ctc import evaluate_ctc_events
from traccuracy.track_errors._divisions import evaluate_division_events

from ._base import Metric
from ._compute_complete_tracks_by_length import compute_complete_tracks_by_length

if TYPE_CHECKING:
    from typing import Literal

    from traccuracy.matchers import Matched


[docs] class CompleteTracksByLength(Metric): """Fraction of fully correct tracks as a function of track length. This is the CTC-BIO Complete Tracks metric generalized to every track length: for each length from 1 to ``max_length`` (in frames), it computes the accuracy (number correct / number total) of the ground truth track segments that span that many frames. At the maximum length this reduces to ``CompleteTracks``. Length is measured in frames (time difference), not edge count. A segment of length N spans N frames from start to end. For example: - A segment of length 1 spans 1 frame (node at t=0 to node at t=1) - A segment of length 2 spans 2 frames (node at t=0 to node at t=2) Skip edges that span multiple frames are decomposed into individual single-frame edges, each carrying the skip edge's correctness. For example, a skip edge from t=0 to t=3 contributes three length-1 segments (t=0->1, t=1->2, t=2->3), and likewise contributes to the length-2 and length-3 totals. At division points, all branches are included in the same segment - a segment is only correct if all branches are correct. A segment is counted as correct if: - The starting node is a true positive - All edges along the path are true positives Important counting rules: - Isolated nodes (nodes with no outgoing edges) are NOT counted - Skip edges are decomposed into single-frame edges, so they contribute at every length they span (see above) - Tracks shorter than length N still contribute 1 to the total for length N (correct iff the entire track is correct) This metric helps identify whether tracking errors occur more frequently in short or long tracks, providing granular insight into tracking quality at different temporal scales. Args: max_length (int | None): Maximum track length in frames to evaluate. The default is None, which uses gt_tracks.end - gt_tracks.start lineages: If True, evaluate on full lineages (connected components). If False, evaluate on tracklets (segments between divisions). error_type: "basic" or "ctc" error classification scheme The compute function returns a results dictionary with three lists, each indexed by track length (index 0 = length 1, index 1 = length 2, etc.): - ``correct`` - number of correct segments at each length - ``total`` - total number of segments at each length - ``accuracy`` - correct/total at each length, or np.nan if total is 0 """ def __init__( self, max_length: int | None = None, lineages: bool = True, error_type: Literal["basic", "ctc"] = "basic", ): # CTC supports many-to-one because it doesn't use division error classification # (divisions handled via WRONG_SEMANTIC edge flags instead) # Basic errors require one-to-one for proper division error classification valid_matches = ["one-to-one", "many-to-one"] super().__init__(valid_matches) if error_type not in ["ctc", "basic"]: raise ValueError(f"Unrecognized error type {error_type}. Should be 'ctc' or 'basic'") self.max_length = max_length self.lineages = lineages self.error_type: Literal["basic", "ctc"] = error_type def _compute( self, matched: Matched, relax_skips_gt: bool = False, relax_skips_pred: bool = False, ) -> dict: """Compute complete tracks by length for the matched object. Args: matched: Matched data object to compute metrics on relax_skips_gt: If True, check if skips in the ground truth graph have an equivalent multi-edge path in predicted graph relax_skips_pred: If True, check if skips in the predicted graph have an equivalent multi-edge path in ground truth graph Returns: Dictionary with "correct", "total", and "accuracy" lists, each of length max_length (index 0 = length 1, etc.). """ if matched.gt_graph.start_frame is None or matched.gt_graph.end_frame is None: warnings.warn( "GT graph has no frame range (empty graph). Returning empty results.", stacklevel=2, ) return {} # Run error classification if self.error_type == "basic": # Run division error classification (only for basic errors) # CTC handles division errors via WRONG_SEMANTIC edge flags # Note: evaluate_division_events internally calls classify_basic_errors evaluate_division_events( matched, relax_skips_gt=relax_skips_gt, relax_skips_pred=relax_skips_pred ) else: if relax_skips_gt or relax_skips_pred: warnings.warn( "CTC metrics do not support relaxing skip edges. " "Ignoring relax_skips_gt and relax_skips_pred.", stacklevel=2, ) evaluate_ctc_events(matched) if self.max_length is None: max_length = matched.gt_graph.end_frame - matched.gt_graph.start_frame - 1 else: max_length = self.max_length # Compute segment counts segment_counts = compute_complete_tracks_by_length( matched, max_length, self.lineages, error_type=self.error_type, relax_skips_gt=relax_skips_gt, relax_skips_pred=relax_skips_pred, ) # Convert to results dict with lists (index 0 = length 1, etc.) correct_list: list[int] = [] total_list: list[int] = [] accuracy_list: list[float] = [] for length in range(1, max_length + 1): correct, total = segment_counts.get(length, (0, 0)) correct_list.append(correct) total_list.append(total) accuracy_list.append(correct / total if total > 0 else np.nan) return { "correct": correct_list, "total": total_list, "accuracy": accuracy_list, }