Skip to content

Commit a06e855

Browse files
Update evaluation for IHCs
1 parent fe722e0 commit a06e855

File tree

7 files changed

+282
-85
lines changed

7 files changed

+282
-85
lines changed

flamingo_tools/validation.py

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ def fetch_data_for_evaluation(
4343
seg_name: str = "SGN_v2",
4444
z_extent: int = 0,
4545
components_for_postprocessing: Optional[List[int]] = None,
46+
cochlea: Optional[str] = None,
47+
extra_data: Optional[str] = None,
4648
) -> Tuple[np.ndarray, pd.DataFrame]:
4749
"""Fetch segmentation from S3 matching the annotation path for evaluation.
4850
@@ -53,28 +55,31 @@ def fetch_data_for_evaluation(
5355
z_extent: Additional z-slices to load from the segmentation.
5456
components_for_postprocessing: The component ids for restricting the segmentation to.
5557
Choose [1] for the default componentn containing the helix.
58+
cochlea: Optional name of the cochlea.
59+
extra_data: Extra data to fetch.
5660
5761
Returns:
5862
The segmentation downloaded from the S3 bucket.
5963
The annotations loaded from pandas and matching the segmentation.
6064
"""
6165
# Load the annotations and normalize them for the given z-extent.
6266
annotations = pd.read_csv(annotation_path)
63-
annotations = annotations.drop(columns="index")
67+
if "index" in annotations.columns:
68+
annotations = annotations.drop(columns="index")
6469
if z_extent == 0: # If we don't have a z-extent then we just drop the first axis and rename the other two.
6570
annotations = annotations.drop(columns="axis-0")
6671
annotations = annotations.rename(columns={"axis-1": "axis-0", "axis-2": "axis-1"})
67-
else: # Otherwise we have to center the first axis.
68-
# TODO
69-
raise NotImplementedError
7072

7173
# Load the segmentaiton from cache path if it is given and if it is already cached.
7274
if cache_path is not None and os.path.exists(cache_path):
7375
segmentation = imageio.imread(cache_path)
7476
return segmentation, annotations
7577

7678
# Parse which ID and which cochlea from the name.
77-
cochlea, slice_id = _parse_annotation_path(annotation_path)
79+
if cochlea is None:
80+
cochlea, slice_id = _parse_annotation_path(annotation_path)
81+
else:
82+
_, slice_id = _parse_annotation_path(annotation_path)
7883

7984
# Open the S3 connection, get the path to the SGN segmentation in S3.
8085
internal_path = os.path.join(cochlea, "images", "ome-zarr", f"{seg_name}.ome.zarr")
@@ -111,6 +116,14 @@ def fetch_data_for_evaluation(
111116
if cache_path is not None:
112117
imageio.imwrite(cache_path, segmentation, compression="zlib")
113118

119+
if extra_data is not None:
120+
internal_path = os.path.join(cochlea, "images", "ome-zarr", f"{extra_data}.ome.zarr")
121+
s3_store, fs = get_s3_path(internal_path, bucket_name=BUCKET_NAME, service_endpoint=SERVICE_ENDPOINT)
122+
input_key = "s0"
123+
with zarr.open(s3_store, mode="r") as f:
124+
extra_im_data = f[input_key][roi]
125+
return segmentation, annotations, extra_im_data
126+
114127
return segmentation, annotations
115128

116129

@@ -347,6 +360,62 @@ def union(a, b):
347360
return consensus_df, unmatched_df
348361

349362

363+
def match_detections(
364+
detections: np.ndarray,
365+
annotations: np.ndarray,
366+
max_dist: float
367+
):
368+
"""One-to-one matching between 3-D detections and ground-truth points.
369+
370+
Args:
371+
detections: N x 3 candidate detections.
372+
annotations: M x 3 ground-truth annotations for the reference points.
373+
max_dist: Maximum Euclidean distance allowed for a match.
374+
375+
Returns:
376+
Indices in `detections` that were matched (true positives).
377+
Indices in `annotations` that were matched (true positives).
378+
Unmatched detection indices (false positives).
379+
Unmatched annotation indices (false negatives).
380+
"""
381+
det = np.asarray(detections, dtype=float)
382+
ann = np.asarray(annotations, dtype=float)
383+
N, M = len(det), len(ann)
384+
385+
# trivial corner cases --------------------------------------------------------
386+
if N == 0:
387+
return np.empty(0, int), np.empty(0, int), np.empty(0, int), np.arange(M)
388+
if M == 0:
389+
return np.empty(0, int), np.empty(0, int), np.arange(N), np.empty(0, int)
390+
391+
# 1. build sparse radius-filtered distance matrix -----------------------------
392+
tree_det = cKDTree(det)
393+
tree_ann = cKDTree(ann)
394+
coo = tree_det.sparse_distance_matrix(tree_ann, max_dist, output_type="coo_matrix")
395+
396+
if coo.nnz == 0: # nothing is close enough
397+
return np.empty(0, int), np.empty(0, int), np.arange(N), np.arange(M)
398+
399+
cost = np.full((N, M), 5 * max_dist, dtype=float)
400+
cost[coo.row, coo.col] = coo.data # fill only existing edges
401+
402+
# 2. optimal one-to-one assignment (Hungarian) --------------------------------
403+
row_ind, col_ind = linear_sum_assignment(cost)
404+
405+
# Filter assignments that were padded with +∞ cost for non-existent edges
406+
# (linear_sum_assignment automatically does that padding internally).
407+
valid_mask = cost[row_ind, col_ind] <= max_dist
408+
tp_det_ids = row_ind[valid_mask]
409+
tp_ann_ids = col_ind[valid_mask]
410+
assert len(tp_det_ids) == len(tp_ann_ids)
411+
412+
# 3. derive FP / FN -----------------------------------------------------------
413+
fp_det_ids = np.setdiff1d(np.arange(N), tp_det_ids, assume_unique=True)
414+
fn_ann_ids = np.setdiff1d(np.arange(M), tp_ann_ids, assume_unique=True)
415+
416+
return tp_det_ids, tp_ann_ids, fp_det_ids, fn_ann_ids
417+
418+
350419
def for_visualization(segmentation, annotations, matches):
351420
green_red = ["#00FF00", "#FF0000"]
352421

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import os
2+
from glob import glob
3+
4+
import pandas as pd
5+
from flamingo_tools.validation import create_consensus_annotations
6+
7+
ROOT = "/mnt/vast-nhr/projects/nim00007/data/moser/cochlea-lightsheet/AnnotatedImageCrops/F1ValidationIHCs"
8+
ANNOTATION_FOLDERS = ["Annotations_AMD", "Annotations_EK", "Annotations_LR"]
9+
10+
OUTPUT_FOLDER = os.path.join(ROOT, "consensus_annotation")
11+
COLOR = ["blue", "yellow", "orange"]
12+
13+
14+
def match_annotations(image_path):
15+
annotations = {}
16+
prefix = os.path.basename(image_path).split("_")[:3]
17+
prefix = "_".join(prefix)
18+
19+
annotations = {}
20+
for annotation_folder in ANNOTATION_FOLDERS:
21+
all_annotations = glob(os.path.join(ROOT, annotation_folder, "*.csv"))
22+
matches = [ann for ann in all_annotations if os.path.basename(ann).startswith(prefix)]
23+
assert len(matches) == 1
24+
annotation_path = matches[0]
25+
annotations[annotation_folder] = annotation_path
26+
27+
return annotations
28+
29+
30+
def consensus_annotations(image_path, check):
31+
annotation_paths = match_annotations(image_path)
32+
assert len(annotation_paths) == len(ANNOTATION_FOLDERS)
33+
34+
# I tried first with a matching distnce of 8, but that is too conservative.
35+
# A mathing distance of 16 seems better, but might still need to refine this.
36+
matching_distance = 16.0
37+
consensus_annotations, unmatched_annotations = create_consensus_annotations(
38+
annotation_paths, matching_distance=matching_distance, min_matches_for_consensus=2,
39+
)
40+
fname = os.path.basename(image_path)
41+
42+
if check:
43+
import napari
44+
import tifffile
45+
46+
consensus_annotations = consensus_annotations[["axis-0", "axis-1", "axis-2"]].values
47+
unmatched_annotators = unmatched_annotations.annotator.values
48+
unmatched_annotations = unmatched_annotations[["axis-0", "axis-1", "axis-2"]].values
49+
50+
image = tifffile.imread(image_path)
51+
v = napari.Viewer()
52+
v.add_image(image)
53+
v.add_points(consensus_annotations, face_color="green")
54+
v.add_points(
55+
unmatched_annotations,
56+
properties={"annotator": unmatched_annotators},
57+
face_color="annotator",
58+
face_color_cycle=COLOR, # TODO reorder
59+
)
60+
v.title = os.path.basename(fname)
61+
napari.run()
62+
else:
63+
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
64+
consensus_annotations = consensus_annotations[["axis-0", "axis-1", "axis-2"]]
65+
consensus_annotations.insert(0, "annotator", ["consensus"] * len(consensus_annotations))
66+
unmatched_annotations = unmatched_annotations[["axis-0", "axis-1", "axis-2", "annotator"]]
67+
annotations = pd.concat([consensus_annotations, unmatched_annotations])
68+
output_path = os.path.join(OUTPUT_FOLDER, fname.replace(".tif", ".csv"))
69+
annotations.to_csv(output_path, index=False)
70+
print("Saved to", output_path)
71+
72+
73+
def main():
74+
import argparse
75+
76+
parser = argparse.ArgumentParser()
77+
parser.add_argument("--images", nargs="+")
78+
parser.add_argument("--check", action="store_true")
79+
args = parser.parse_args()
80+
81+
if args.images is None:
82+
image_paths = sorted(glob(os.path.join(ROOT, "*.tif")))
83+
else:
84+
image_paths = args.images
85+
86+
for image_path in image_paths:
87+
consensus_annotations(image_path, args.check)
88+
89+
90+
if __name__ == "__main__":
91+
main()
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
annotator,tps,fps,fns
2+
Annotations_AMD,24,0,4
3+
Annotations_EK,27,1,1
4+
Annotations_LR,24,6,4
5+
Annotations_AMD,5,0,0
6+
Annotations_EK,4,0,1
7+
Annotations_LR,4,2,1
8+
Annotations_AMD,31,1,1
9+
Annotations_EK,29,2,3
10+
Annotations_LR,31,1,1
11+
Annotations_AMD,5,0,2
12+
Annotations_EK,6,0,1
13+
Annotations_LR,5,2,2
14+
Annotations_AMD,26,0,1
15+
Annotations_EK,27,0,0
16+
Annotations_LR,27,4,0
17+
Annotations_AMD,31,0,0
18+
Annotations_EK,30,0,1
19+
Annotations_LR,31,2,0
20+
Annotations_AMD,7,0,2
21+
Annotations_EK,8,0,1
22+
Annotations_LR,8,1,1
23+
Annotations_AMD,28,1,2
24+
Annotations_EK,30,1,0
25+
Annotations_LR,27,6,3
26+
Annotations_AMD,43,0,0
27+
Annotations_EK,42,0,1
28+
Annotations_LR,43,4,0
29+
Annotations_AMD,34,0,2
30+
Annotations_EK,35,0,1
31+
Annotations_LR,36,1,0
32+
Annotations_AMD,5,0,0
33+
Annotations_EK,5,0,0
34+
Annotations_LR,5,0,0
35+
Annotations_AMD,31,0,2
36+
Annotations_EK,33,0,0
37+
Annotations_LR,33,1,0
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import os
2+
from glob import glob
3+
4+
import pandas as pd
5+
from flamingo_tools.validation import match_detections
6+
7+
ROOT = "/mnt/vast-nhr/projects/nim00007/data/moser/cochlea-lightsheet/AnnotatedImageCrops/F1ValidationIHCs"
8+
ANNOTATION_FOLDERS = ["Annotations_AMD", "Annotations_EK", "Annotations_LR"]
9+
CONSENSUS_FOLDER = "consensus_annotation"
10+
11+
12+
def match_annotations(consensus_path):
13+
annotations = {}
14+
prefix = os.path.basename(consensus_path).split("_")[:3]
15+
prefix = "_".join(prefix)
16+
17+
annotations = {}
18+
for annotation_folder in ANNOTATION_FOLDERS:
19+
all_annotations = glob(os.path.join(ROOT, annotation_folder, "*.csv"))
20+
matches = [ann for ann in all_annotations if os.path.basename(ann).startswith(prefix)]
21+
assert len(matches) == 1
22+
annotation_path = matches[0]
23+
annotations[annotation_folder] = annotation_path
24+
25+
return annotations
26+
27+
28+
def main():
29+
consensus_files = sorted(glob(os.path.join(ROOT, CONSENSUS_FOLDER, "*.csv")))
30+
31+
results = {
32+
"annotator": [],
33+
"tps": [],
34+
"fps": [],
35+
"fns": [],
36+
}
37+
for consensus_file in consensus_files:
38+
consensus = pd.read_csv(consensus_file)
39+
consensus = consensus[consensus.annotator == "consensus"][["axis-0", "axis-1", "axis-2"]]
40+
41+
annotations = match_annotations(consensus_file)
42+
for name, annotation_path in annotations.items():
43+
annotation = pd.read_csv(annotation_path)[["axis-0", "axis-1", "axis-2"]]
44+
tp, _, fp, fn = match_detections(annotation, consensus, max_dist=12.0)
45+
results["annotator"].append(name)
46+
results["tps"].append(len(tp))
47+
results["fps"].append(len(fp))
48+
results["fns"].append(len(fn))
49+
50+
results = pd.DataFrame(results)
51+
print(results)
52+
results.to_csv("consensus_evaluation.csv", index=False)
53+
54+
55+
if __name__ == "__main__":
56+
main()

scripts/validation/IHCs/run_evaluation.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
ROOT = "/mnt/vast-nhr/projects/nim00007/data/moser/cochlea-lightsheet/AnnotatedImageCrops/F1ValidationIHCs"
1010
# ANNOTATION_FOLDERS = ["AnnotationsEK", "AnnotationsAMD", "AnnotationsLR"]
11-
ANNOTATION_FOLDERS = ["Annotations_AMD", "Annotations_LR"]
11+
# ANNOTATION_FOLDERS = ["Annotations_AMD", "Annotations_LR"]
12+
ANNOTATION_FOLDERS = ["consensus_annotation"]
1213

1314

1415
def run_evaluation(root, annotation_folders, result_file, cache_folder):
@@ -25,7 +26,7 @@ def run_evaluation(root, annotation_folders, result_file, cache_folder):
2526
os.makedirs(cache_folder, exist_ok=True)
2627

2728
for folder in annotation_folders:
28-
annotator = folder[len("Annotations"):]
29+
annotator = "consensus" if folder == "consensus_annotation" else folder[len("Annotations"):]
2930
annotations = sorted(glob(os.path.join(root, folder, "*.csv")))
3031
for annotation_path in annotations:
3132
print(annotation_path)

0 commit comments

Comments
 (0)