From 7874a48a08336fcc77bc9231e48d26f76cd38664 Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Wed, 17 Dec 2025 09:21:23 +0100 Subject: [PATCH 01/17] Removed the check, if children are existing in the next frame. --- ctc_metrics/metrics/biological/bc.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ctc_metrics/metrics/biological/bc.py b/ctc_metrics/metrics/biological/bc.py index 93bf46b..42065d6 100644 --- a/ctc_metrics/metrics/biological/bc.py +++ b/ctc_metrics/metrics/biological/bc.py @@ -21,6 +21,7 @@ def get_ids_that_ends_with_split( counts = counts[parents > 0] parents = parents[parents > 0] ends_with_split = parents[counts > 1] + print("ends_with_split:", ends_with_split) return ends_with_split @@ -83,12 +84,12 @@ def is_matching( ind = np.argwhere(mr == id_ref).squeeze() if mc[ind] != id_comp: return False - # Compare children - mr, mc = np.asarray(mapped_ref[t2 + 1]), np.asarray(mapped_comp[t2 + 1]) - if not np.all(np.isin(comp_children, mc)): - return False - if not np.all(np.isin(mr[np.isin(mc, comp_children)], ref_children)): - return False + # # Compare children ### WHAT IS A CORRECT DETECTED MITOSIS? CHILDREN ARE NOT IMPORTANT NOW + # mr, mc = np.concatenate(mapped_ref[t2 + 1]), np.concatenate(mapped_comp[t2 + 1]) + # if not np.all(np.isin(comp_children, mc)): + # return False + # if not np.all(np.isin(mr[np.isin(mc, comp_children)], ref_children)): + # return False return True def raw_division_metrics( From 6873f90ec056a8c399e4e83a2fd1a815fddc662c Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Wed, 17 Dec 2025 09:24:13 +0100 Subject: [PATCH 02/17] Fixed Pylint and Readme --- README.md | 2 +- ctc_metrics/metrics/biological/bc.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fee7ff6..df1aee1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # Py-CTCMetrics A python implementation of the metrics used in the paper -[CHOTA: A Higher Order Accuracy Metric for Cell Tracking](https://arxiv.org/abs/2408.11571) by +[CHOTA: A Higher Order Accuracy Metric for Cell Tracking](https://link.springer.com/chapter/10.1007/978-3-031-91721-9_8) by *Timo Kaiser et al.*. The code is designed to evaluate tracking results in the format of the [Cell-Tracking-Challenge](https://celltrackingchallenge.net/) but can also be used diff --git a/ctc_metrics/metrics/biological/bc.py b/ctc_metrics/metrics/biological/bc.py index 42065d6..d307496 100644 --- a/ctc_metrics/metrics/biological/bc.py +++ b/ctc_metrics/metrics/biological/bc.py @@ -77,14 +77,14 @@ def is_matching( if len(ref_children) != len(comp_children): return False # Compare parents - t1, t2 = min(tr, tc), max(tr, tc) + t1, _ = min(tr, tc), max(tr, tc) mr, mc = mapped_ref[t1], mapped_comp[t1] if np.sum(mc == id_comp) < 1 or np.sum(mr == id_ref) != 1: return False ind = np.argwhere(mr == id_ref).squeeze() if mc[ind] != id_comp: return False - # # Compare children ### WHAT IS A CORRECT DETECTED MITOSIS? CHILDREN ARE NOT IMPORTANT NOW + # # Compare children ### WHAT IS A CORRECT DETECTED MITOSIS? # mr, mc = np.concatenate(mapped_ref[t2 + 1]), np.concatenate(mapped_comp[t2 + 1]) # if not np.all(np.isin(comp_children, mc)): # return False From aeeba188320ff1a3019905a520fe916eadb253cd Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:32:10 +0100 Subject: [PATCH 03/17] Now, also mitosis are detected, if daughters appear in the future with a timegap > 0. It is similar to the fiji plugin implementation --- ctc_metrics/metrics/biological/bc.py | 74 +++++++++++++++++++--------- 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/ctc_metrics/metrics/biological/bc.py b/ctc_metrics/metrics/biological/bc.py index d307496..29b9c46 100644 --- a/ctc_metrics/metrics/biological/bc.py +++ b/ctc_metrics/metrics/biological/bc.py @@ -21,7 +21,6 @@ def get_ids_that_ends_with_split( counts = counts[parents > 0] parents = parents[parents > 0] ends_with_split = parents[counts > 1] - print("ends_with_split:", ends_with_split) return ends_with_split @@ -54,8 +53,10 @@ def is_matching( mapped_comp: list, ref_children: np.ndarray, comp_children: np.ndarray, - tr: int, - tc: int + t_parent_end_ref: int, + t_parent_end_comp: int, + t_child_start_ref: list, + t_child_start_comp: list, ): """ Checks if the reference and the computed track match. @@ -67,8 +68,10 @@ def is_matching( mapped_comp: The matched labels of the result masks. ref_children: The children ids of the reference track. comp_children: The children ids of the computed track. - tr: The frame of the reference track end. - tc: The frame of the computed track end. + t_parent_end_ref: The frame of the reference track end. + t_parent_end_comp: The frame of the computed track end. + t_child_start_ref: The frame of the reference track start. + t_child_start_comp: The frame of the computed track start. Returns: True if the reference and the computed track match, False otherwise. @@ -77,21 +80,33 @@ def is_matching( if len(ref_children) != len(comp_children): return False # Compare parents - t1, _ = min(tr, tc), max(tr, tc) - mr, mc = mapped_ref[t1], mapped_comp[t1] + t_start = min(t_parent_end_ref, t_parent_end_comp) + mr, mc = mapped_ref[t_start], mapped_comp[t_start] if np.sum(mc == id_comp) < 1 or np.sum(mr == id_ref) != 1: return False ind = np.argwhere(mr == id_ref).squeeze() if mc[ind] != id_comp: return False - # # Compare children ### WHAT IS A CORRECT DETECTED MITOSIS? - # mr, mc = np.concatenate(mapped_ref[t2 + 1]), np.concatenate(mapped_comp[t2 + 1]) - # if not np.all(np.isin(comp_children, mc)): - # return False - # if not np.all(np.isin(mr[np.isin(mc, comp_children)], ref_children)): - # return False + # Compare children + # Iterate over all GT ids and check if the first detection is matched to the correct reference children + matched_children = [] + for i, t_ref in zip(ref_children, t_child_start_ref): + for j, t_comp in zip(comp_children, t_child_start_comp): + t_max = max(t_ref, t_comp) + if i in mapped_ref[t_max] and j in mapped_comp[t_max]: + ind = mapped_ref[t_max].index(i) + if mapped_comp[t_max][ind] == j: + # There is a match! + if j not in matched_children: + matched_children.append(j) + break + + if len(matched_children) != len(ref_children): + return False + return True + def raw_division_metrics( comp_tracks: np.ndarray, ref_tracks: np.ndarray, @@ -101,7 +116,7 @@ def raw_division_metrics( ): """ Computes number of true positives, false positives, and false negatives for divisions. - + Args: comp_tracks: The result tracks. A (n,4) numpy ndarray with columns: - label @@ -137,36 +152,49 @@ def raw_division_metrics( ends_with_split_comp = get_ids_that_ends_with_split(comp_tracks) t_comp = np.asarray([comp_tracks[comp_tracks[:, 0] == comp][0, 2] for comp in ends_with_split_comp]) - - # If there are no divisions in the reference + + # If there are no divisions in the reference if len(ends_with_split_ref) == 0: return (0, len(ends_with_split_comp), 0) - + # If there are no divisions in the computed result if len(ends_with_split_comp) == 0: return (0, 0, len(ends_with_split_ref)) - + # Find all matches between reference and computed branching events (mitosis) matches = [] - for comp, tc in zip(ends_with_split_comp, t_comp): + for comp, t_parent_end_start in zip(ends_with_split_comp, t_comp): # Find potential matches - pot_matches = np.abs(t_ref - tc) <= i + pot_matches = np.abs(t_ref - t_parent_end_start) <= i if len(pot_matches) == 0: continue comp_children = comp_tracks[comp_tracks[:, 3] == comp][:, 0] + t_child_start_comp = [] + for j in comp_children: + t = comp_tracks[comp_tracks[:, 0] == j][0, 1] + t_child_start_comp.append(t) # Evaluate potential matches - for ref, tr in zip( + for ref, t_parent_end_ref in zip( ends_with_split_ref[pot_matches], t_ref[pot_matches] ): ref_children = ref_tracks[ref_tracks[:, 3] == ref][:, 0] + t_child_start_ref = [] + for j in ref_children: + t = ref_tracks[ref_tracks[:, 0] == j][0, 1] + t_child_start_ref.append(t) if is_matching( - comp, ref, mapped_ref, mapped_comp, ref_children, - comp_children, tr, tc + comp, ref, + mapped_ref, mapped_comp, + ref_children, comp_children, + t_parent_end_ref, t_parent_end_start, + t_child_start_ref, + t_child_start_comp, ): matches.append((ref, comp)) return (len(matches), len(ends_with_split_comp) - len(matches), len(ends_with_split_ref) - len(matches)) + def bc( tp: int, fp: int, From 4fbe4a5c625c47b554568d034e77d75560f88095 Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:25:44 +0100 Subject: [PATCH 04/17] A reference to issue #22 --- ctc_metrics/metrics/biological/bc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ctc_metrics/metrics/biological/bc.py b/ctc_metrics/metrics/biological/bc.py index 29b9c46..b1dde39 100644 --- a/ctc_metrics/metrics/biological/bc.py +++ b/ctc_metrics/metrics/biological/bc.py @@ -89,6 +89,7 @@ def is_matching( return False # Compare children # Iterate over all GT ids and check if the first detection is matched to the correct reference children + # See discussion here https://github.com/CellTrackingChallenge/py-ctcmetrics/issues/22 matched_children = [] for i, t_ref in zip(ref_children, t_child_start_ref): for j, t_comp in zip(comp_children, t_child_start_comp): From a5708dcb8a38501765b65d6ae9d26899f57e256b Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:24:16 +0100 Subject: [PATCH 05/17] Fixed Issue #21 --- ctc_metrics/metrics/biological/bc.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ctc_metrics/metrics/biological/bc.py b/ctc_metrics/metrics/biological/bc.py index b1dde39..c660991 100644 --- a/ctc_metrics/metrics/biological/bc.py +++ b/ctc_metrics/metrics/biological/bc.py @@ -214,5 +214,9 @@ def bc( Returns: The branching correctness metric. """ + # Return None if no split is existing in the reference data + if (tp + fn) == 0: + return None + # Calculate BC(i) return calculate_f1_score(tp, fp, fn) From 0287ea37caad114efd8072af03901c8180a6a766 Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:50:10 +0100 Subject: [PATCH 06/17] now really :) Fixed Issue #21 --- ctc_metrics/metrics/biological/bc.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ctc_metrics/metrics/biological/bc.py b/ctc_metrics/metrics/biological/bc.py index c660991..7249041 100644 --- a/ctc_metrics/metrics/biological/bc.py +++ b/ctc_metrics/metrics/biological/bc.py @@ -57,6 +57,7 @@ def is_matching( t_parent_end_comp: int, t_child_start_ref: list, t_child_start_comp: list, + max_i: int, ): """ Checks if the reference and the computed track match. @@ -72,7 +73,7 @@ def is_matching( t_parent_end_comp: The frame of the computed track end. t_child_start_ref: The frame of the reference track start. t_child_start_comp: The frame of the computed track start. - + max_i: The maximal time gap between starts of the reference and computed daughter tracks. Returns: True if the reference and the computed track match, False otherwise. """ @@ -93,6 +94,11 @@ def is_matching( matched_children = [] for i, t_ref in zip(ref_children, t_child_start_ref): for j, t_comp in zip(comp_children, t_child_start_comp): + # Check if start frames of the daughters are close enough <= i_max + temporal_error = abs(t_ref - t_comp) + if temporal_error > max_i: + break + # Verify if children are matching t_max = max(t_ref, t_comp) if i in mapped_ref[t_max] and j in mapped_comp[t_max]: ind = mapped_ref[t_max].index(i) @@ -191,6 +197,7 @@ def raw_division_metrics( t_parent_end_ref, t_parent_end_start, t_child_start_ref, t_child_start_comp, + i ): matches.append((ref, comp)) return (len(matches), len(ends_with_split_comp) - len(matches), len(ends_with_split_ref) - len(matches)) From 5e519fa4ffcdf27b078b95d29a73abc2792cc70c Mon Sep 17 00:00:00 2001 From: "Vladimir Ulman (@Karolina)" Date: Mon, 12 Jan 2026 20:41:07 +0100 Subject: [PATCH 07/17] biological/bc.py is_matching() uses max_i also checking distance of mothers,... ...cosmetic fixes in the function's documentation, t_start variable renamed to t_last_common (when finding the last common timepoint of both dividing mother tracks) --- ctc_metrics/metrics/biological/bc.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ctc_metrics/metrics/biological/bc.py b/ctc_metrics/metrics/biological/bc.py index 7249041..fabc243 100644 --- a/ctc_metrics/metrics/biological/bc.py +++ b/ctc_metrics/metrics/biological/bc.py @@ -73,16 +73,19 @@ def is_matching( t_parent_end_comp: The frame of the computed track end. t_child_start_ref: The frame of the reference track start. t_child_start_comp: The frame of the computed track start. - max_i: The maximal time gap between starts of the reference and computed daughter tracks. + max_i: The maximal time gap between ends of the reference and + computed mother tracks, and beginnings of daughter tracks. Returns: True if the reference and the computed track match, False otherwise. """ # Check if the number of children is the same if len(ref_children) != len(comp_children): return False - # Compare parents - t_start = min(t_parent_end_ref, t_parent_end_comp) - mr, mc = mapped_ref[t_start], mapped_comp[t_start] + # Compare parents, for temporal distance and then for spatial overlap + if abs(t_parent_end_ref - t_parent_end_comp) > max_i: + return False + t_last_common = min(t_parent_end_ref, t_parent_end_comp) + mr, mc = mapped_ref[t_last_common], mapped_comp[t_last_common] if np.sum(mc == id_comp) < 1 or np.sum(mr == id_ref) != 1: return False ind = np.argwhere(mr == id_ref).squeeze() From e9d1d0e0a2a70f57b5ca1dc5d3e801d0b5a4c2e9 Mon Sep 17 00:00:00 2001 From: "Vladimir Ulman (@Karolina)" Date: Mon, 12 Jan 2026 20:42:08 +0100 Subject: [PATCH 08/17] biological/bc.py is_matching() cosmetics changes in commentary and doc lines --- ctc_metrics/metrics/biological/bc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ctc_metrics/metrics/biological/bc.py b/ctc_metrics/metrics/biological/bc.py index fabc243..de5c727 100644 --- a/ctc_metrics/metrics/biological/bc.py +++ b/ctc_metrics/metrics/biological/bc.py @@ -101,7 +101,7 @@ def is_matching( temporal_error = abs(t_ref - t_comp) if temporal_error > max_i: break - # Verify if children are matching + # Verify if children are overlapping spatially t_max = max(t_ref, t_comp) if i in mapped_ref[t_max] and j in mapped_comp[t_max]: ind = mapped_ref[t_max].index(i) @@ -148,7 +148,7 @@ def raw_division_metrics( matched labels of the result masks in the respective frame. The elements are in the same order as the corresponding elements in mapped_ref. - i: The maximal allowed error in frames. + i: The maximal allowed temporal error (offset) in frames. Returns: Tuple of true positives, false positives, and false negatives. From fe154024c7a9527f3caa44556ec94d93d3d022d4 Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Tue, 20 Jan 2026 08:54:49 +0100 Subject: [PATCH 09/17] Allow to choose arbitrary i for BC(i) --- README.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index df1aee1..82efa06 100644 --- a/README.md +++ b/README.md @@ -156,23 +156,23 @@ Per default, all given metrics are evaluated. You can also select the metrics you are interested in to avoid the calculation of metrics that are not in your interest. Additional arguments to select a subset of specific metrics are: -| Argument | Description | -| --- |-----------------------------------------------------------------| -| --valid | Check if the result has valid format | -| --det | The DET detection metric | -| --seg | The SEG segmentation metric | -| --tra | The TRA tracking metric | -| --lnk | The LNK linking metric | -| --ct | The CT (complete tracks) metric | -| --tf | The TF (track fraction) metric | -| --bc | The BC(i) (branching correctness) metric | -| --cca | The CCA (cell cycle accuracy) metric | -| --mota | The MOTA (Multiple Object Tracking Accuracy) metric | -| --hota | The HOTA (Higher Order Tracking Accuracy) metric | -| --idf1 | The IDF1 (ID F1) metric | -| --chota | The CHOTA (Cell-specific Higher Order Tracking Accuracy) metric | -| --mtml | The MT (Mostly Tracked) and ML (Mostly Lost) metrics | -| --faf | The FAF (False Alarm per Frame) metric | +| Argument | Description | +|----------|--------------------------------------------------------------------------------------| +| --valid | Check if the result has valid format | +| --det | The DET detection metric | +| --seg | The SEG segmentation metric | +| --tra | The TRA tracking metric | +| --lnk | The LNK linking metric | +| --ct | The CT (complete tracks) metric | +| --tf | The TF (track fraction) metric | +| --bc i | The BC(i) (branching correctness) metric. Set i >= 3 to an calculate BC(0) to BC(i) | +| --cca | The CCA (cell cycle accuracy) metric | +| --mota | The MOTA (Multiple Object Tracking Accuracy) metric | +| --hota | The HOTA (Higher Order Tracking Accuracy) metric | +| --idf1 | The IDF1 (ID F1) metric | +| --chota | The CHOTA (Cell-specific Higher Order Tracking Accuracy) metric | +| --mtml | The MT (Mostly Tracked) and ML (Mostly Lost) metrics | +| --faf | The FAF (False Alarm per Frame) metric | --- To use the evaluation protocol in your python code, the code can be imported From 75342f55d3566698815c04a4d71e7d6a98253b02 Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Tue, 20 Jan 2026 08:55:58 +0100 Subject: [PATCH 10/17] Allow to choose arbitrary i for BC(i) --- ctc_metrics/scripts/evaluate.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/ctc_metrics/scripts/evaluate.py b/ctc_metrics/scripts/evaluate.py index da650f0..cb1c2d0 100644 --- a/ctc_metrics/scripts/evaluate.py +++ b/ctc_metrics/scripts/evaluate.py @@ -2,6 +2,7 @@ from os.path import join, basename from multiprocessing import Pool, cpu_count import numpy as np +import warnings from ctc_metrics.metrics import ( valid, det, seg, tra, ct, tf, bc, raw_division_metrics, cca, mota, hota, idf1, chota, mtml, faf, @@ -171,6 +172,19 @@ def calculate_metrics( traj["labels_comp_merged"] = new_labels traj["mapped_comp_merged"] = new_mapped + # Check if a manual i was defined for BC(i) + max_i_for_bci = 3 + for m in metrics: + if m.startswith("BC("): + try: + i = int(m[3:-1]) + if i > max_i_for_bci: + max_i_for_bci = i + if "BC" not in metrics: + metrics.append("BC") + except ValueError: + warnings.warn(f"{m} is not a valid metric identifier!.") + # Prepare intermediate results graph_operations = {} if "DET" in metrics or "TRA" in metrics: @@ -225,7 +239,7 @@ def calculate_metrics( traj["labels_ref"], traj["mapped_ref"], traj["mapped_comp"]) if "BC" in metrics: - for i in range(4): + for i in range(max_i_for_bci+1): tp, fp, fn = raw_division_metrics(comp_tracks, ref_tracks, traj["mapped_ref"], traj["mapped_comp"], i=i) @@ -243,13 +257,13 @@ def calculate_metrics( if "CT" in metrics and "BC" in metrics and \ "CCA" in metrics and "TF" in metrics: - for i in range(4): + for i in range(max_i_for_bci+1): results[f"BIO({i})"] = bio( results["CT"], results["TF"], results[f"BC({i})"], results["CCA"]) if "BIO" in results and "LNK" in results: - for i in range(4): + for i in range(max_i_for_bci+1): results[f"OP_CLB({i})"] = op_clb( results["LNK"], results[f"BIO({i})"]) @@ -365,7 +379,7 @@ def parse_args(): parser.add_argument('--tra', action="store_true") parser.add_argument('--ct', action="store_true") parser.add_argument('--tf', action="store_true") - parser.add_argument('--bc', action="store_true") + parser.add_argument('--bc', type=int, default=0) parser.add_argument('--cca', action="store_true") parser.add_argument('--mota', action="store_true") parser.add_argument('--hota', action="store_true") @@ -391,7 +405,7 @@ def main(): ("TRA", args.tra), ("CT", args.ct), ("TF", args.tf), - ("BC", args.bc), + (f"BC({args.bc})", args.bc), ("CCA", args.cca), ("MOTA", args.mota), ("HOTA", args.hota), From 6120b28ba0432d2b94cea9cfe65d63f607fcece6 Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Tue, 20 Jan 2026 08:58:20 +0100 Subject: [PATCH 11/17] Cosmetic for PyLint --- ctc_metrics/metrics/biological/bc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ctc_metrics/metrics/biological/bc.py b/ctc_metrics/metrics/biological/bc.py index de5c727..35bdad8 100644 --- a/ctc_metrics/metrics/biological/bc.py +++ b/ctc_metrics/metrics/biological/bc.py @@ -58,7 +58,7 @@ def is_matching( t_child_start_ref: list, t_child_start_comp: list, max_i: int, -): +): # pylint: disable=too-many-arguments """ Checks if the reference and the computed track match. From aee776bde98ad454c92c86783ba39d9be6f0d670 Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Tue, 20 Jan 2026 09:22:45 +0100 Subject: [PATCH 12/17] optimized print out --- ctc_metrics/scripts/evaluate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ctc_metrics/scripts/evaluate.py b/ctc_metrics/scripts/evaluate.py index cb1c2d0..17f95bb 100644 --- a/ctc_metrics/scripts/evaluate.py +++ b/ctc_metrics/scripts/evaluate.py @@ -426,7 +426,8 @@ def main(): res = evaluate_sequence( res=args.res, gt=args.gt, metrics=metrics, threads=args.num_threads) # Visualize and store results - print_results(res) + for k,v in res.items(): + print(f"{k}: {v}") if args.csv_file is not None: store_results(args.csv_file, res) From 4f6b93c78ba6b9afe22cf866e3051ec4912480c0 Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:07:23 +0100 Subject: [PATCH 13/17] Made the daughter2daughter and mother2mother matching unique, different to the AOGM measure where a computed mask can be assigned to multiple result masks (like in the FiJI plugin). --- ctc_metrics/metrics/biological/bc.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ctc_metrics/metrics/biological/bc.py b/ctc_metrics/metrics/biological/bc.py index 35bdad8..8227fad 100644 --- a/ctc_metrics/metrics/biological/bc.py +++ b/ctc_metrics/metrics/biological/bc.py @@ -91,6 +91,9 @@ def is_matching( ind = np.argwhere(mr == id_ref).squeeze() if mc[ind] != id_comp: return False + # Check if the parent match is unique, i.e. the computed track is only assigned to one gt + if mc.count(mc[ind]) != 1: + return False # Compare children # Iterate over all GT ids and check if the first detection is matched to the correct reference children # See discussion here https://github.com/CellTrackingChallenge/py-ctcmetrics/issues/22 @@ -106,10 +109,12 @@ def is_matching( if i in mapped_ref[t_max] and j in mapped_comp[t_max]: ind = mapped_ref[t_max].index(i) if mapped_comp[t_max][ind] == j: - # There is a match! - if j not in matched_children: - matched_children.append(j) - break + # Check if the daughter match is unique, i.e. the computed track is only assigned to one gt + if mapped_comp[t_max].count(j) == 1: + # There is a match! + if j not in matched_children: + matched_children.append(j) + break if len(matched_children) != len(ref_children): return False @@ -203,6 +208,7 @@ def raw_division_metrics( i ): matches.append((ref, comp)) + return (len(matches), len(ends_with_split_comp) - len(matches), len(ends_with_split_ref) - len(matches)) From 8c06dc8a5f347caf7edb0959830a27202a0f9d2b Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:59:44 +0100 Subject: [PATCH 14/17] fixed print out --- ctc_metrics/scripts/evaluate.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ctc_metrics/scripts/evaluate.py b/ctc_metrics/scripts/evaluate.py index 17f95bb..979e9d8 100644 --- a/ctc_metrics/scripts/evaluate.py +++ b/ctc_metrics/scripts/evaluate.py @@ -426,8 +426,12 @@ def main(): res = evaluate_sequence( res=args.res, gt=args.gt, metrics=metrics, threads=args.num_threads) # Visualize and store results - for k,v in res.items(): - print(f"{k}: {v}") + print_out = res if type(res) is list else [(args.res, res)] + for name, output in print_out: + print("----------") + print(f"{name}") + for k,v in output.items(): + print(f"{k}: {v}") if args.csv_file is not None: store_results(args.csv_file, res) From a80651a10b7e2cb8b79aeaf128ff3f1285defec5 Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Tue, 20 Jan 2026 12:05:59 +0100 Subject: [PATCH 15/17] fixed pylint --- ctc_metrics/metrics/biological/bc.py | 16 +++++++++------- ctc_metrics/scripts/evaluate.py | 13 +++---------- ctc_metrics/utils/handle_results.py | 11 +++++------ 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/ctc_metrics/metrics/biological/bc.py b/ctc_metrics/metrics/biological/bc.py index 8227fad..ae3d3a5 100644 --- a/ctc_metrics/metrics/biological/bc.py +++ b/ctc_metrics/metrics/biological/bc.py @@ -103,18 +103,20 @@ def is_matching( # Check if start frames of the daughters are close enough <= i_max temporal_error = abs(t_ref - t_comp) if temporal_error > max_i: - break + continue # Verify if children are overlapping spatially t_max = max(t_ref, t_comp) + # Check if the daughter match is unique, i.e. the computed track is only assigned to one gt + if mapped_comp[t_max].count(j) != 1: + continue if i in mapped_ref[t_max] and j in mapped_comp[t_max]: ind = mapped_ref[t_max].index(i) if mapped_comp[t_max][ind] == j: - # Check if the daughter match is unique, i.e. the computed track is only assigned to one gt - if mapped_comp[t_max].count(j) == 1: - # There is a match! - if j not in matched_children: - matched_children.append(j) - break + # There is a match! + if j not in matched_children: + matched_children.append(j) + break + if len(matched_children) != len(ref_children): return False diff --git a/ctc_metrics/scripts/evaluate.py b/ctc_metrics/scripts/evaluate.py index 979e9d8..516c58a 100644 --- a/ctc_metrics/scripts/evaluate.py +++ b/ctc_metrics/scripts/evaluate.py @@ -1,8 +1,8 @@ +import warnings import argparse from os.path import join, basename from multiprocessing import Pool, cpu_count import numpy as np -import warnings from ctc_metrics.metrics import ( valid, det, seg, tra, ct, tf, bc, raw_division_metrics, cca, mota, hota, idf1, chota, mtml, faf, @@ -177,9 +177,7 @@ def calculate_metrics( for m in metrics: if m.startswith("BC("): try: - i = int(m[3:-1]) - if i > max_i_for_bci: - max_i_for_bci = i + max_i_for_bci = max(max_i_for_bci, int(m[3:-1])) if "BC" not in metrics: metrics.append("BC") except ValueError: @@ -426,12 +424,7 @@ def main(): res = evaluate_sequence( res=args.res, gt=args.gt, metrics=metrics, threads=args.num_threads) # Visualize and store results - print_out = res if type(res) is list else [(args.res, res)] - for name, output in print_out: - print("----------") - print(f"{name}") - for k,v in output.items(): - print(f"{k}: {v}") + print_results(res) if args.csv_file is not None: store_results(args.csv_file, res) diff --git a/ctc_metrics/utils/handle_results.py b/ctc_metrics/utils/handle_results.py index c8dcd54..4bfd9a0 100644 --- a/ctc_metrics/utils/handle_results.py +++ b/ctc_metrics/utils/handle_results.py @@ -8,23 +8,22 @@ def print_results(results: dict): results: A dictionary containing the results. """ - def print_line(metrics: dict): + def print_block(metrics: dict): """ Prints a line of the table. Args: metrics: A list containing the arguments for the line. """ - - print(*[f"{k}: {'N/A' if v is None else float(v):.5},\t" for k, v - in metrics.items()]) + for k, v in metrics.items(): + print(f"{k}: {'N/A' if v is None else float(v):.5}") if isinstance(results, dict): - print_line(results) + print_block(results) elif isinstance(results, list): for res in results: print(res[0], end=":\t\t") - print_line(res[1]) + print_block(res[1]) def store_results( From e3cafd646fd3dcc85fbcd12d2433f28b56420a87 Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Tue, 20 Jan 2026 12:10:43 +0100 Subject: [PATCH 16/17] fixed pylint --- ctc_metrics/metrics/biological/bc.py | 2 +- ctc_metrics/scripts/evaluate.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ctc_metrics/metrics/biological/bc.py b/ctc_metrics/metrics/biological/bc.py index ae3d3a5..0f988d9 100644 --- a/ctc_metrics/metrics/biological/bc.py +++ b/ctc_metrics/metrics/biological/bc.py @@ -58,7 +58,7 @@ def is_matching( t_child_start_ref: list, t_child_start_comp: list, max_i: int, -): # pylint: disable=too-many-arguments +): # pylint: disable=too-many-arguments,too-complex """ Checks if the reference and the computed track match. diff --git a/ctc_metrics/scripts/evaluate.py b/ctc_metrics/scripts/evaluate.py index 516c58a..33c1e76 100644 --- a/ctc_metrics/scripts/evaluate.py +++ b/ctc_metrics/scripts/evaluate.py @@ -129,7 +129,7 @@ def calculate_metrics( segm: dict, metrics: list = None, is_valid: bool = None, -): # pylint: disable=too-complex +): # pylint: disable=too-complex,too-many-branches """ Calculate metrics for given data. From d278b493369ba04ef2efe1740eaffb15cb40e712 Mon Sep 17 00:00:00 2001 From: Timo Kaiser <34896723+TimoK93@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:31:09 +0100 Subject: [PATCH 17/17] Bump version from 1.3.2 to 1.3.3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 692c302..219d0dd 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="py-ctcmetrics", - version="1.3.2", + version="1.3.3", packages=find_packages(), install_requires=[ "numpy",