diff --git a/evalinstseg/vis_io.py b/evalinstseg/vis_io.py new file mode 100644 index 0000000..53e2471 --- /dev/null +++ b/evalinstseg/vis_io.py @@ -0,0 +1,39 @@ +# Support functions to unify visualization I/O in HDF and PNG +import numpy as np +import h5py +from skimage import io + +def _mid_slice(arr): + arr = np.asarray(arr) + if arr.ndim == 3: # (Z,Y,X) + return arr[arr.shape[0] // 2] + if arr.ndim == 4 and arr.shape[-1] in (3, 4): # (Z,Y,X,C) + return arr[arr.shape[0] // 2] + return arr + +def save_vis_hdf(outFn, data_dict, compression="gzip"): + """ + data_dict: mapping name -> ndarray, will be written under volumes/ + """ + with h5py.File(outFn + "_vis.hdf", "w") as f: + for name, arr in data_dict.items(): + f.create_dataset(f"volumes/{name}", data=arr, compression=compression) + +def export_vis_pngs(outFn, data_dict): + """ + Writes PNG quicklooks next to the HDF. Scalar layers assumed in [0,1]. + RGB layers assumed uint8 or float in [0,255] / [0,1]. + """ + for name, arr in data_dict.items(): + a = _mid_slice(arr) + + if a.ndim == 3 and a.shape[-1] in (3, 4): + # RGB(A) + if a.dtype != np.uint8: + a = np.clip(a, 0, 1) + a = (a * 255).astype(np.uint8) + io.imsave(outFn + f"_{name}.png", a) + else: + # scalar + a = np.clip(a, 0, 1) + io.imsave(outFn + f"_{name}.png", (a * 255).astype(np.uint8)) diff --git a/evalinstseg/visualize.py b/evalinstseg/visualize.py index b0cac49..ccbda46 100644 --- a/evalinstseg/visualize.py +++ b/evalinstseg/visualize.py @@ -1,17 +1,15 @@ +# evalinstseg/visualize.py import logging -import h5py from matplotlib.colors import to_rgb import numpy as np import scipy.ndimage from skimage import io -logger = logging.getLogger(__name__) +from .vis_io import save_vis_hdf, export_vis_pngs +logger = logging.getLogger(__name__) -# colors selected based on: -# - at least somewhat usable in case of color blindness -# - no two colors should be too similar gt_cmap_ = [ "#88B04B", "#9F00A7", "#EFC050", "#34568B", "#E47A2E", "#BC70A4", "#92A8D1", "#A3B18A", "#45B8AC", "#6B5B95", @@ -19,7 +17,6 @@ "#CE3175", "#00A591", "#EDD59E", "#1E7145", "#E9FF70", ] - pred_cmap_ = [ "#FDAC53", "#9BB7D4", "#B55A30", "#F5DF4D", "#0072B5", "#A0DAA9", "#E9897E", "#00A170", "#926AA6", "#EFE1CE", @@ -36,40 +33,35 @@ def rgb(idx, cmap): def proj_label(lbl): """project label map along the first axis (z), rgb-aware""" dst = np.zeros(lbl.shape[1:], dtype=np.uint8) - for i in range(lbl.shape[0]-1, -1, -1): - dst[np.max(dst, axis=-1)==0,:] = lbl[i][np.max(dst, axis=-1)==0,:] - + for i in range(lbl.shape[0] - 1, -1, -1): + dst[np.max(dst, axis=-1) == 0, :] = lbl[i][np.max(dst, axis=-1) == 0, :] return dst -def paint_boundary(labels_rel, label, target): - """converts dense label map to boundary label map - (for 3d label maps only slice with largest xy-extend is filled) +def paint_boundary(labels_rel, label_id, target): + """ + converts dense label map to boundary label map + NOTE: border_value=0 to avoid edge artifacts. """ if len(labels_rel.shape) == 3: - coords_ = np.nonzero(labels_rel == label) + coords_ = np.nonzero(labels_rel == label_id) coords = {} for z, y, x in zip(*coords_): coords.setdefault(z, []).append((z, y, x)) - max_z = -1 - max_z_len = -1 - for z, v in coords.items(): - if len(v) > max_z_len: - max_z_len = len(v) - max_z = z - tmp = np.zeros_like(labels_rel[max_z], dtype=np.float32) - tmp = labels_rel[max_z]==label + max_z = max(coords.keys(), key=lambda z: len(coords[z])) + tmp = (labels_rel[max_z] == label_id) + struct = scipy.ndimage.generate_binary_structure(2, 2) eroded_tmp = scipy.ndimage.binary_erosion( tmp, iterations=1, structure=struct, - border_value=1) + border_value=1, + ) bnd = np.logical_xor(tmp, eroded_tmp) target[max_z][bnd] = 1 else: - tmp = np.zeros_like(labels_rel, dtype=np.float32) - tmp = labels_rel==label + tmp = (labels_rel == label_id) struct = scipy.ndimage.generate_binary_structure(2, 2) eroded_tmp = scipy.ndimage.binary_erosion( tmp, @@ -80,39 +72,29 @@ def paint_boundary(labels_rel, label, target): target[bnd] = 1 -def visualize_nuclei( - gt_labels_rel, pred_labels_rel, locMat, gt_ind, pred_ind, th, outFn): +def visualize_nuclei(gt_labels_rel, pred_labels_rel, locMat, gt_ind, pred_ind, th, outFn, export_png=True): """visualize nuclei (blob-like) segmentation results""" # GT-basiert - vis_tp = np.zeros_like(gt_labels_rel, dtype=np.float32) - vis_fn = np.zeros_like(gt_labels_rel, dtype=np.float32) - vis_tp_seg = np.zeros_like(gt_labels_rel, dtype=np.float32) - vis_fn_seg = np.zeros_like(gt_labels_rel, dtype=np.float32) + vis_tp = np.zeros_like(gt_labels_rel, dtype=np.float32) + vis_fn = np.zeros_like(gt_labels_rel, dtype=np.float32) + vis_tp_seg = np.zeros_like(gt_labels_rel, dtype=np.float32) + vis_fn_seg = np.zeros_like(gt_labels_rel, dtype=np.float32) vis_fn_seg_bnd = np.zeros_like(gt_labels_rel, dtype=np.float32) - # Pred-basiert - vis_fp = np.zeros_like(pred_labels_rel, dtype=np.float32) - vis_tp_seg2 = np.zeros_like(pred_labels_rel, dtype=np.float32) - vis_fp_seg = np.zeros_like(pred_labels_rel, dtype=np.float32) + # Pred-basiert + vis_fp = np.zeros_like(pred_labels_rel, dtype=np.float32) + vis_tp_seg2 = np.zeros_like(pred_labels_rel, dtype=np.float32) + vis_fp_seg = np.zeros_like(pred_labels_rel, dtype=np.float32) vis_fp_seg_bnd = np.zeros_like(pred_labels_rel, dtype=np.float32) - # determine number of labels num_gt_labels = int(np.max(gt_labels_rel)) num_pred_labels = int(np.max(pred_labels_rel)) labels_gt = list(range(1, num_gt_labels + 1)) labels_pred = list(range(1, num_pred_labels + 1)) - cntrs_gt = scipy.ndimage.center_of_mass( - gt_labels_rel > 0, - gt_labels_rel, - labels_gt - ) - cntrs_pred = scipy.ndimage.center_of_mass( - pred_labels_rel > 0, - pred_labels_rel, - labels_pred -) + cntrs_gt = scipy.ndimage.center_of_mass(gt_labels_rel > 0, gt_labels_rel, labels_gt) + cntrs_pred = scipy.ndimage.center_of_mass(pred_labels_rel > 0, pred_labels_rel, labels_pred) sz = 1 for gti, pi in zip(gt_ind, pred_ind): @@ -121,80 +103,71 @@ def visualize_nuclei( paint_boundary(gt_labels_rel, gti, vis_fn_seg_bnd) vis_fp_seg[pred_labels_rel == pi] = 1 paint_boundary(pred_labels_rel, pi, vis_fp_seg_bnd) - cntr = cntrs_gt[gti-1] - idx = tuple(int(round(c)) for c in cntr) + idx = tuple(int(round(c)) for c in cntrs_gt[gti - 1]) vis_fn[idx] = 1 - cntr = cntrs_pred[pi-1] - idx = tuple(int(round(c)) for c in cntr) + idx = tuple(int(round(c)) for c in cntrs_pred[pi - 1]) vis_fp[idx] = 1 else: vis_tp_seg[gt_labels_rel == gti] = 1 - cntr = cntrs_gt[gti - 1] - idx = tuple(int(round(c)) for c in cntr) + idx = tuple(int(round(c)) for c in cntrs_gt[gti - 1]) vis_tp[idx] = 1 vis_tp_seg2[pred_labels_rel == pi] = 1 - vis_tp = scipy.ndimage.gaussian_filter(vis_tp, sz, truncate=sz) - for label in range(1, num_gt_labels + 1): - if label in gt_ind: + # FN + for label_id in range(1, num_gt_labels + 1): + if label_id in gt_ind: continue - vis_fn_seg[gt_labels_rel == label] = 1 - paint_boundary(gt_labels_rel, label, vis_fn_seg_bnd) - cntr = cntrs_gt[label - 1] - idx = tuple(int(round(c)) for c in cntr) + vis_fn_seg[gt_labels_rel == label_id] = 1 + paint_boundary(gt_labels_rel, label_id, vis_fn_seg_bnd) + idx = tuple(int(round(c)) for c in cntrs_gt[label_id - 1]) vis_fn[idx] = 1 - vis_fn = scipy.ndimage.gaussian_filter(vis_fn, sz, truncate=sz) - for label in range(1, num_pred_labels + 1): - if label in pred_ind: + + # FP + for label_id in range(1, num_pred_labels + 1): + if label_id in pred_ind: continue - vis_fp_seg[pred_labels_rel == label] = 1 - paint_boundary(pred_labels_rel, label, vis_fp_seg_bnd) - cntr = cntrs_pred[label - 1] - idx = tuple(int(round(c)) for c in cntr) + vis_fp_seg[pred_labels_rel == label_id] = 1 + paint_boundary(pred_labels_rel, label_id, vis_fp_seg_bnd) + idx = tuple(int(round(c)) for c in cntrs_pred[label_id - 1]) vis_fp[idx] = 1 + + # blur markers + vis_tp = scipy.ndimage.gaussian_filter(vis_tp, sz, truncate=sz) + vis_fn = scipy.ndimage.gaussian_filter(vis_fn, sz, truncate=sz) vis_fp = scipy.ndimage.gaussian_filter(vis_fp, sz, truncate=sz) - # Division by 0 guard - max_tp = np.max(vis_tp) - if max_tp > 0: - vis_tp = vis_tp / max_tp - - max_fp = np.max(vis_fp) - if max_fp > 0: - vis_fp = vis_fp / max_fp - - max_fn = np.max(vis_fn) - if max_fn > 0: - vis_fn = vis_fn / max_fn - with h5py.File(outFn + "_vis.hdf", 'w') as fi: - fi.create_dataset('volumes/vis_tp', data=vis_tp, compression='gzip') - fi.create_dataset('volumes/vis_fp', data=vis_fp, compression='gzip') - fi.create_dataset('volumes/vis_fn', data=vis_fn, compression='gzip') - fi.create_dataset( - 'volumes/vis_tp_seg', data=vis_tp_seg, compression='gzip') - fi.create_dataset( - 'volumes/vis_tp_seg2', data=vis_tp_seg2, compression='gzip') - fi.create_dataset( - 'volumes/vis_fp_seg', data=vis_fp_seg, compression='gzip') - fi.create_dataset( - 'volumes/vis_fn_seg', data=vis_fn_seg, compression='gzip') - fi.create_dataset( - 'volumes/vis_fp_seg_bnd', data=vis_fp_seg_bnd, compression='gzip') - fi.create_dataset( - 'volumes/vis_fn_seg_bnd', data=vis_fn_seg_bnd, compression='gzip') + # normalize markers to [0,1] + for arr in (vis_tp, vis_fn, vis_fp): + m = np.max(arr) + if m > 0: + arr /= m + + data = { + "vis_tp": vis_tp, + "vis_fp": vis_fp, + "vis_fn": vis_fn, + "vis_tp_seg": vis_tp_seg, + "vis_tp_seg2": vis_tp_seg2, + "vis_fp_seg": vis_fp_seg, + "vis_fn_seg": vis_fn_seg, + "vis_fp_bnd": vis_fp_seg_bnd, + "vis_fn_bnd": vis_fn_seg_bnd, + } + + save_vis_hdf(outFn, data) + if export_png: + export_vis_pngs(outFn, data) def visualize_neurons( gt_labels_rel, pred_labels_rel, gt_ind, pred_ind, outFn, - fp_ind, fs_ind, fn_ind, fm_gt_ind, fm_pred_ind, fp_ind_only_bg): + fp_ind, fs_ind, fn_ind, fm_gt_ind, fm_pred_ind, fp_ind_only_bg, export_hdf=True): """visualize neuron (tree-like) segmentation results - Note ---- currently unused: unused: fs_ind , fm_pred_ind, fp_ind_only_bg """ - if len(gt_labels_rel.shape) == 4: gt = np.max(gt_labels_rel, axis=0) else: @@ -203,16 +176,16 @@ def visualize_neurons( pred = np.max(pred_labels_rel, axis=0) else: pred = pred_labels_rel - # unify shapes for vis if only z differs (instance stack) if gt.ndim == 3 and pred.ndim == 3 and gt.shape[1:] == pred.shape[1:] and gt.shape[0] != pred.shape[0]: - gt = np.max(gt, axis=0) + gt = np.max(gt, axis=0) pred = np.max(pred, axis=0) - assert gt.shape[1:] == pred.shape[1:], f"Spatial shape mismatch: gt {gt.shape}, pred {pred.shape}" + assert gt.shape == pred.shape, f"Spatial shape mismatch: gt {gt.shape}, pred {pred.shape}" + num_gt = int(np.max(gt_labels_rel)) + num_pred = int(np.max(pred_labels_rel)) - num_gt = np.max(gt_labels_rel) - num_pred = np.max(pred_labels_rel) + # base RGB canvas dst = np.zeros_like(gt, dtype=np.uint8) dst = np.stack([dst, dst, dst], axis=-1) @@ -220,28 +193,25 @@ def visualize_neurons( # one color per instance vis = np.zeros_like(dst) for i in range(1, num_gt + 1): - vis[gt == i] = rgb(i-1, gt_cmap_) - - # If already 2D, keep as is - if vis.ndim == 3: - mip = vis - else: - mip = proj_label(vis) - - mip_gt_mask = np.max(mip>0, axis=-1) - io.imsave(outFn + '_gt.png', mip.astype(np.uint8)) + vis[gt == i] = rgb(i - 1, gt_cmap_) + if vis.ndim == 3: + mip_gt = vis + else: + mip_gt = proj_label(vis) + mip_gt_mask = np.max(mip_gt > 0, axis=-1) + io.imsave(outFn + "_gt.png", mip_gt.astype(np.uint8)) # visualize pred # one color per instance vis = np.zeros_like(dst) for i in range(1, num_pred + 1): - vis[pred == i] = rgb(i-1, pred_cmap_) - if vis.ndim == 3: - mip = vis - else: - mip = proj_label(vis) - mip_pred_mask = np.max(mip>0, axis=-1) - io.imsave(outFn + '_pred.png', mip.astype(np.uint8)) + vis[pred == i] = rgb(i - 1, pred_cmap_) + if vis.ndim == 3: + mip_pred = vis + else: + mip_pred = proj_label(vis) + mip_pred_mask = np.max(mip_pred > 0, axis=-1) + io.imsave(outFn + "_pred.png", mip_pred.astype(np.uint8)) # visualize tp pred + fp + fs # tp pred in color @@ -249,62 +219,61 @@ def visualize_neurons( # fn pixels in gray vis = np.zeros_like(dst) for i in pred_ind: - vis[pred == i] = rgb(i-1, pred_cmap_) + vis[pred == i] = rgb(i - 1, pred_cmap_) for i in fp_ind: vis[pred == i] = [255, 0, 0] + if vis.ndim == 3: + mip_tp_pred_fp_fs = vis + else: + mip_tp_pred_fp_fs = proj_label(vis) + mask = np.logical_and(mip_gt_mask, np.logical_not(np.max(mip_tp_pred_fp_fs > 0, axis=-1))) + mip_tp_pred_fp_fs[mask] = [200, 200, 200] + io.imsave(outFn + "_tp_pred_fp_fs.png", mip_tp_pred_fp_fs.astype(np.uint8)) - if vis.ndim == 3: - mip = vis - else: - mip = proj_label(vis) - - mask = np.logical_and(mip_gt_mask, np.logical_not(np.max(mip > 0, axis=-1))) - mip[mask] = [200, 200, 200] - io.imsave(outFn + '_tp_pred_fp_fs.png', mip.astype(np.uint8)) - - # visualize fn/fm - # fn in color - # fm (false merges) in red - # fp pixels in gray + # ---- FN / FM ---- vis = np.zeros_like(dst, dtype=np.uint8) - fm_merged = np.unique(np.array( - [ind for inds in fm_gt_ind for ind in inds]).flatten()) + fm_merged = np.unique(np.array([ind for inds in fm_gt_ind for ind in inds]).flatten()) if len(fm_gt_ind) else np.array([]) for i in fn_ind: if i not in fm_merged: - vis[gt == i] = rgb(i-1, gt_cmap_) + vis[gt == i] = rgb(i - 1, gt_cmap_) for i in fm_merged: if i not in gt_ind: vis[gt == i] = [255, 0, 0] + if vis.ndim == 3: + mip_fn_fm = vis + else: + mip_fn_fm = proj_label(vis) + mask = np.logical_and(mip_pred_mask, np.logical_not(np.max(mip_fn_fm > 0, axis=-1))) + mip_fn_fm[mask] = [200, 200, 200] + io.imsave(outFn + "_fn_fm.png", mip_fn_fm.astype(np.uint8)) - if vis.ndim == 3: - mip = vis - else: - mip = proj_label(vis) - - mask = np.logical_and(mip_pred_mask, np.logical_not(np.max(mip > 0, axis=-1))) - mip[mask] = [200, 200, 200] - io.imsave(outFn + '_fn_fm.png', mip.astype(np.uint8)) - - # visualize fn/fm v2 - # tp gt in color - # fn in bright red - # fm in dark red - # fp pixels in gray + # visualize tp pred + fp + fs + # tp pred in color + # fp and fs (false splits) in red + # fn pixels in gray vis = np.zeros_like(dst, dtype=np.uint8) for i in gt_ind: - vis[gt == i] = rgb(i-1, gt_cmap_) + vis[gt == i] = rgb(i - 1, gt_cmap_) for i in fn_ind: if i not in fm_merged: vis[gt == i] = [255, 64, 64] for i in fm_merged: - if i not in gt_ind: - vis[gt == i] = [192, 0, 0] - - if vis.ndim == 3: - mip = vis - else: # (Z, Y, X, 3) -> project along Z - mip = proj_label(vis) - - mask = np.logical_and(mip_pred_mask, np.logical_not(np.max(mip > 0, axis=-1))) - mip[mask] = [200, 200, 200] - io.imsave(outFn + '_fn_fm_v2.png', mip.astype(np.uint8)) + if i not in gt_ind: + vis[gt == i] = [192, 0, 0] + if vis.ndim == 3: + mip_fn_fm_v2 = vis + else: + mip_fn_fm_v2 = proj_label(vis) + mask = np.logical_and(mip_pred_mask, np.logical_not(np.max(mip_fn_fm_v2 > 0, axis=-1))) + mip_fn_fm_v2[mask] = [200, 200, 200] + io.imsave(outFn + "_fn_fm_v2.png", mip_fn_fm_v2.astype(np.uint8)) + + if export_hdf: + data = { + "gt_rgb": mip_gt.astype(np.uint8), + "pred_rgb": mip_pred.astype(np.uint8), + "tp_pred_fp_fs_rgb": mip_tp_pred_fp_fs.astype(np.uint8), + "fn_fm_rgb": mip_fn_fm.astype(np.uint8), + "fn_fm_v2_rgb": mip_fn_fm_v2.astype(np.uint8), + } + save_vis_hdf(outFn, data) diff --git a/tests/images/nuclein.png b/tests/images/nuclein.png new file mode 100644 index 0000000..38e8f8f Binary files /dev/null and b/tests/images/nuclein.png differ diff --git a/tests/results/vis/nuclei_realimg_vis.hdf b/tests/results/vis/nuclei_realimg_vis.hdf new file mode 100644 index 0000000..1b06a0d Binary files /dev/null and b/tests/results/vis/nuclei_realimg_vis.hdf differ diff --git a/tests/results/vis/nuclei_realimg_vis_fn.png b/tests/results/vis/nuclei_realimg_vis_fn.png new file mode 100644 index 0000000..21e7e3d Binary files /dev/null and b/tests/results/vis/nuclei_realimg_vis_fn.png differ diff --git a/tests/results/vis/nuclei_realimg_vis_fn_bnd.png b/tests/results/vis/nuclei_realimg_vis_fn_bnd.png new file mode 100644 index 0000000..21e7e3d Binary files /dev/null and b/tests/results/vis/nuclei_realimg_vis_fn_bnd.png differ diff --git a/tests/results/vis/nuclei_realimg_vis_fn_seg.png b/tests/results/vis/nuclei_realimg_vis_fn_seg.png new file mode 100644 index 0000000..21e7e3d Binary files /dev/null and b/tests/results/vis/nuclei_realimg_vis_fn_seg.png differ diff --git a/tests/results/vis/nuclei_realimg_vis_fp.png b/tests/results/vis/nuclei_realimg_vis_fp.png new file mode 100644 index 0000000..21e7e3d Binary files /dev/null and b/tests/results/vis/nuclei_realimg_vis_fp.png differ diff --git a/tests/results/vis/nuclei_realimg_vis_fp_bnd.png b/tests/results/vis/nuclei_realimg_vis_fp_bnd.png new file mode 100644 index 0000000..21e7e3d Binary files /dev/null and b/tests/results/vis/nuclei_realimg_vis_fp_bnd.png differ diff --git a/tests/results/vis/nuclei_realimg_vis_fp_seg.png b/tests/results/vis/nuclei_realimg_vis_fp_seg.png new file mode 100644 index 0000000..21e7e3d Binary files /dev/null and b/tests/results/vis/nuclei_realimg_vis_fp_seg.png differ diff --git a/tests/results/vis/nuclei_realimg_vis_tp.png b/tests/results/vis/nuclei_realimg_vis_tp.png new file mode 100644 index 0000000..e5049a5 Binary files /dev/null and b/tests/results/vis/nuclei_realimg_vis_tp.png differ diff --git a/tests/results/vis/nuclei_realimg_vis_tp_seg.png b/tests/results/vis/nuclei_realimg_vis_tp_seg.png new file mode 100644 index 0000000..2578f49 Binary files /dev/null and b/tests/results/vis/nuclei_realimg_vis_tp_seg.png differ diff --git a/tests/results/vis/nuclei_realimg_vis_tp_seg2.png b/tests/results/vis/nuclei_realimg_vis_tp_seg2.png new file mode 100644 index 0000000..2578f49 Binary files /dev/null and b/tests/results/vis/nuclei_realimg_vis_tp_seg2.png differ diff --git a/tests/test_functions.py b/tests/test_functions.py index c0416aa..c8c35e0 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -4,6 +4,8 @@ import os import h5py import zarr +from pathlib import Path + from evalinstseg.evaluate import evaluate_volume from evalinstseg.match import instance_mask, greedy_many_to_many_matching @@ -267,6 +269,101 @@ def test_real_data_format(self): self.assertEqual(pred_loaded.shape, (15, 1, 50, 50)) self.assertEqual(gt_loaded.shape, (1, 50, 50)) +import os +import unittest +import tempfile + +import numpy as np +import h5py +from imageio.v3 import imread +from skimage.measure import label +from skimage.morphology import remove_small_objects + +from evalinstseg.evaluate import evaluate_volume + + +class TestNucleiVisualizationRealImage(unittest.TestCase): + @staticmethod + def _repo_path_relative_to_this_test(*parts): + # This resolves paths relative to the current test file location + here = os.path.dirname(__file__) + return os.path.join(here, *parts) + + @staticmethod + def _load_mask_as_instances_png(png_path, threshold=128, min_size=20): + img = imread(png_path) + if img.ndim == 3: + img = img[..., 0] # handle RGB/RGBA + + # Robust binarization: avoids tiny non-zero artifacts becoming objects + bw = img >= threshold + + # Optional cleanup: remove tiny speckles that create phantom CCs + bw = remove_small_objects(bw, min_size=min_size) + + lbl = label(bw).astype(np.int32) + return lbl + + def test_visualize_nuclei_from_real_png_writes_vis_hdf(self): + png_path = self._repo_path_relative_to_this_test("images", "nuclein.png") + self.assertTrue(os.path.exists(png_path), f"Missing test image: {png_path}") + + lbl2d = self._load_mask_as_instances_png(png_path, threshold=128, min_size=20) + n = int(lbl2d.max()) + self.assertGreater(n, 0, "No instances found in test image after preprocessing") + + gt = lbl2d[None, ...] + pred = lbl2d[None, ...].copy() # perfect match => should produce TP + + with tempfile.TemporaryDirectory() as td: + + here = Path(__file__).resolve().parent + vis_dir = here / "results" / "vis" + vis_dir.mkdir(parents=True, exist_ok=True) + + outFn = str(vis_dir / "nuclei_realimg") + metrics = evaluate_volume( + gt, pred, ndim=2, outFn=outFn, + localization_criterion="iou", + assignment_strategy="greedy", + visualize=True, + visualize_type="nuclei", + evaluate_false_labels=True + ) + + vis_path = outFn + "_vis.hdf" + self.assertTrue(os.path.exists(vis_path), "Expected *_vis.hdf not written") + + # Verify datasets exist and behave as expected for perfect match + with h5py.File(vis_path, "r") as f: + required = [ + "volumes/vis_tp", + "volumes/vis_fp", + "volumes/vis_fn", + "volumes/vis_tp_seg", + "volumes/vis_tp_seg2", + "volumes/vis_fp_seg", + "volumes/vis_fn_seg", + ] + for k in required: + self.assertIn(k, f, f"Missing dataset in vis file: {k}") + + # Boundaries: support both older/newer key names + fp_bnd_keys = ("volumes/vis_fp_seg_bnd", "volumes/vis_fp_bnd") + fn_bnd_keys = ("volumes/vis_fn_seg_bnd", "volumes/vis_fn_bnd") + + self.assertTrue(any(k in f for k in fp_bnd_keys), + f"Missing FP boundary dataset (tried {fp_bnd_keys})") + self.assertTrue(any(k in f for k in fn_bnd_keys), + f"Missing FN boundary dataset (tried {fn_bnd_keys})") + + # Perfect match sanity checks + self.assertGreater(np.max(f["volumes/vis_tp"][:]), 0.0) + self.assertEqual(np.max(f["volumes/vis_fn_seg"][:]), 0.0) + self.assertEqual(np.max(f["volumes/vis_fp_seg"][:]), 0.0) + + + if __name__ == '__main__': # Create test suite @@ -278,7 +375,8 @@ def test_real_data_format(self): TestGreedyMatching, TestDataPreprocessing, TestLocalizationCriterion, - TestEndToEnd + TestEndToEnd, + TestNucleiVisualizationRealImage, ] for test_class in test_classes: