labelimage-tools 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ """Reusable utilities for 2-D labeled tissue segmentation images."""
2
+
3
+ from .adjacency import (
4
+ adjacency_from_labels,
5
+ adjacency_pairs_from_labels,
6
+ adjacency_with_contact_from_labels,
7
+ adjacency_with_unique_from_labels,
8
+ border_labels,
9
+ centroids_from_labels,
10
+ get_centroids,
11
+ graph_from_labels,
12
+ label_is_border,
13
+ label_pixel_counts,
14
+ )
15
+ from .coloring import (
16
+ apply_color_lut_int,
17
+ color_planar_with_variety,
18
+ dsatur_color,
19
+ rebalance_K_colors,
20
+ refine_to_K_colors,
21
+ show_map_with_colors,
22
+ )
23
+ from .contours import ordered_contour_from_mask, ordered_contours_from_labels
24
+ from .io import (
25
+ load_img,
26
+ load_label_graph,
27
+ load_label_image,
28
+ save_img,
29
+ save_label_graph,
30
+ save_label_graph_from_labels,
31
+ save_label_image,
32
+ )
33
+ from .junctions import (
34
+ Junction,
35
+ cluster_junctions_with_labels,
36
+ junction_pixels_with_labels,
37
+ junctions_from_labels,
38
+ merge_close_junctions,
39
+ )
40
+ from .plotting import (
41
+ draw_graph,
42
+ label_map,
43
+ plot_adjacency_graph,
44
+ plot_contours,
45
+ plot_junctions,
46
+ plot_label_boundaries,
47
+ plot_label_image,
48
+ )
49
+ from .preprocessing import (
50
+ crop_to_foreground_bbox,
51
+ dialate_labels,
52
+ dilate_labels,
53
+ erode_labels,
54
+ fill_internal_gaps_edt,
55
+ find_non_self_connected_labels,
56
+ load_image_pipeline,
57
+ remove_non_self_connected_bits,
58
+ shuffle_labels,
59
+ skeletonize_dilate,
60
+ skeletonize_erode,
61
+ skeletonize_labels,
62
+ )
63
+ from .validation import unique_labels, validate_label_image
64
+
65
+ __all__ = [
66
+ "Junction",
67
+ "adjacency_from_labels",
68
+ "adjacency_pairs_from_labels",
69
+ "adjacency_with_contact_from_labels",
70
+ "adjacency_with_unique_from_labels",
71
+ "apply_color_lut_int",
72
+ "border_labels",
73
+ "centroids_from_labels",
74
+ "cluster_junctions_with_labels",
75
+ "color_planar_with_variety",
76
+ "crop_to_foreground_bbox",
77
+ "dialate_labels",
78
+ "dilate_labels",
79
+ "draw_graph",
80
+ "dsatur_color",
81
+ "erode_labels",
82
+ "fill_internal_gaps_edt",
83
+ "find_non_self_connected_labels",
84
+ "get_centroids",
85
+ "graph_from_labels",
86
+ "junction_pixels_with_labels",
87
+ "junctions_from_labels",
88
+ "label_is_border",
89
+ "label_pixel_counts",
90
+ "label_map",
91
+ "load_image_pipeline",
92
+ "load_img",
93
+ "load_label_graph",
94
+ "load_label_image",
95
+ "merge_close_junctions",
96
+ "ordered_contour_from_mask",
97
+ "ordered_contours_from_labels",
98
+ "plot_adjacency_graph",
99
+ "plot_contours",
100
+ "plot_junctions",
101
+ "plot_label_boundaries",
102
+ "plot_label_image",
103
+ "rebalance_K_colors",
104
+ "refine_to_K_colors",
105
+ "remove_non_self_connected_bits",
106
+ "save_label_graph",
107
+ "save_label_graph_from_labels",
108
+ "save_img",
109
+ "save_label_image",
110
+ "show_map_with_colors",
111
+ "shuffle_labels",
112
+ "skeletonize_dilate",
113
+ "skeletonize_erode",
114
+ "skeletonize_labels",
115
+ "unique_labels",
116
+ "validate_label_image",
117
+ ]
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from scipy import ndimage as ndi
5
+
6
+ from .validation import unique_labels
7
+
8
+
9
+ def _clip_padded_slice(
10
+ slc: tuple[slice, slice],
11
+ shape: tuple[int, ...],
12
+ padding: int,
13
+ ) -> tuple[slice, slice]:
14
+ """Pad a 2-D slice tuple, clipping the result to image bounds."""
15
+ return (
16
+ slice(max(0, slc[0].start - padding), min(shape[0], slc[0].stop + padding)),
17
+ slice(max(0, slc[1].start - padding), min(shape[1], slc[1].stop + padding)),
18
+ )
19
+
20
+
21
+ def _slice_from_coords(coords: np.ndarray) -> tuple[slice, slice]:
22
+ """Build the tight 2-D bounding-box slice around coordinate rows."""
23
+ mins = coords.min(axis=0)
24
+ maxs = coords.max(axis=0) + 1
25
+ return (slice(int(mins[0]), int(maxs[0])), slice(int(mins[1]), int(maxs[1])))
26
+
27
+
28
+ def _compact_problematic_labels(
29
+ labels: np.ndarray,
30
+ problematic: list[int],
31
+ ) -> tuple[np.ndarray, list[int]]:
32
+ """
33
+ Remap sparse/problematic labels to dense positive ids in one image pass.
34
+
35
+ ``ndi.find_objects`` is fast for dense positive labels, but cannot directly
36
+ handle negative labels or very large sparse labels without creating a huge
37
+ list. This helper creates a compact temporary image whose values are
38
+ ``1..M`` only where the original image contains one of the problematic
39
+ labels. The output order is sorted and mapped back by the caller.
40
+ """
41
+ sorted_labels = sorted(problematic)
42
+ lookup = np.asarray(sorted_labels, dtype=labels.dtype)
43
+ compact = np.zeros(labels.shape, dtype=np.int32)
44
+
45
+ indices = np.searchsorted(lookup, labels)
46
+ in_range = indices < lookup.size
47
+ matches = np.zeros(labels.shape, dtype=bool)
48
+ matches[in_range] = lookup[indices[in_range]] == labels[in_range]
49
+ compact[matches] = indices[matches] + 1
50
+ return compact, sorted_labels
51
+
52
+
53
+ def label_slices(
54
+ labels,
55
+ *,
56
+ background=0,
57
+ include_background: bool = False,
58
+ padding: int = 0,
59
+ max_direct_label: int = 100_000,
60
+ max_manual_labels: int = 100,
61
+ ) -> dict[int, tuple[slice, slice]]:
62
+ """
63
+ Return global bounding-box slices for labels in a 2-D label image.
64
+
65
+ Parameters
66
+ ----------
67
+ labels : np.ndarray
68
+ 2-D integer label image.
69
+ background : int, optional
70
+ Background label value. Default is ``0``.
71
+ include_background : bool, optional
72
+ If ``False`` (default), the background label is excluded. If ``True``,
73
+ the background is included when present in the image.
74
+ padding : int, optional
75
+ Number of pixels to add around each bounding box. Padding is clipped to
76
+ the image boundary.
77
+ max_direct_label : int, optional
78
+ Largest positive label handled by the direct ``ndi.find_objects`` fast
79
+ path. Larger labels are handled by the sparse-label fallback.
80
+ max_manual_labels : int, optional
81
+ Maximum number of sparse/problematic labels to handle with bounded
82
+ per-label scans. When there are more, labels are compacted in one pass
83
+ and processed with ``ndi.find_objects``.
84
+
85
+ Returns
86
+ -------
87
+ dict[int, tuple[slice, slice]]
88
+ Mapping from original label value to global ``(row_slice, col_slice)``.
89
+
90
+ Notes
91
+ -----
92
+ Ordinary positive labels use ``scipy.ndimage.find_objects`` directly. This
93
+ preserves the windowed design of the original tools, where expensive
94
+ per-label work happens only inside local crops. Negative labels, zero-valued
95
+ foreground labels, and very large sparse labels are still supported without
96
+ requiring a label-indexed list of length ``max_label + 1``.
97
+ """
98
+ labels = np.asarray(labels)
99
+ padding = int(padding)
100
+ max_direct_label = int(max_direct_label)
101
+ max_manual_labels = int(max_manual_labels)
102
+ if labels.ndim != 2:
103
+ raise ValueError("labels must be a 2-D array")
104
+ if padding < 0:
105
+ raise ValueError("padding must be non-negative")
106
+ if max_direct_label < 1:
107
+ raise ValueError("max_direct_label must be positive")
108
+ if max_manual_labels < 0:
109
+ raise ValueError("max_manual_labels must be non-negative")
110
+
111
+ values = [
112
+ int(label)
113
+ for label in unique_labels(
114
+ labels,
115
+ background=background,
116
+ include_background=include_background,
117
+ )
118
+ ]
119
+ if not values:
120
+ return {}
121
+
122
+ shape = labels.shape
123
+ slices: dict[int, tuple[slice, slice]] = {}
124
+
125
+ direct = [label for label in values if 0 < label <= max_direct_label]
126
+ problematic = [label for label in values if label <= 0 or label > max_direct_label]
127
+
128
+ if direct:
129
+ objects = ndi.find_objects(labels, max_label=max_direct_label)
130
+ for label in direct:
131
+ slc = objects[label - 1]
132
+ if slc is not None:
133
+ slices[label] = _clip_padded_slice(slc, shape, padding)
134
+
135
+ if len(problematic) <= max_manual_labels:
136
+ for label in problematic:
137
+ coords = np.argwhere(labels == label)
138
+ if coords.size:
139
+ slices[label] = _clip_padded_slice(_slice_from_coords(coords), shape, padding)
140
+ elif problematic:
141
+ compact, original_labels = _compact_problematic_labels(labels, problematic)
142
+ objects = ndi.find_objects(compact, max_label=len(original_labels))
143
+ for compact_id, label in enumerate(original_labels, start=1):
144
+ slc = objects[compact_id - 1]
145
+ if slc is not None:
146
+ slices[label] = _clip_padded_slice(slc, shape, padding)
147
+
148
+ return {label: slices[label] for label in values if label in slices}
@@ -0,0 +1,298 @@
1
+ import json
2
+ from collections import namedtuple
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+
7
+ from ._optional import optional_import
8
+ from .typing import Cont, Neig
9
+
10
+ LabelGraphData = namedtuple(
11
+ "LabelGraphData",
12
+ ["neighbors", "contacts", "centroids", "pixel_counts", "metadata"],
13
+ )
14
+
15
+
16
+ def _json_default(value):
17
+ if isinstance(value, np.integer):
18
+ return int(value)
19
+ if isinstance(value, np.floating):
20
+ return float(value)
21
+ if isinstance(value, np.ndarray):
22
+ return value.tolist()
23
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
24
+
25
+
26
+ def _infer_graph_format(path, format: str) -> str:
27
+ fmt = format.lower()
28
+ if fmt != "auto":
29
+ if fmt not in {"npz", "json", "graphml", "gexf"}:
30
+ raise ValueError("format must be one of 'auto', 'npz', 'json', 'graphml', or 'gexf'")
31
+ return fmt
32
+ suffix = Path(path).suffix.lower()
33
+ if suffix == ".npz":
34
+ return "npz"
35
+ if suffix == ".json":
36
+ return "json"
37
+ if suffix == ".graphml":
38
+ return "graphml"
39
+ if suffix == ".gexf":
40
+ return "gexf"
41
+ raise ValueError(
42
+ "could not infer graph format from suffix; use .npz, .json, .graphml, or .gexf"
43
+ )
44
+
45
+
46
+ def _graph_arrays(
47
+ neighbors: Neig,
48
+ *,
49
+ contacts: Cont | None = None,
50
+ centroids: dict[int, np.ndarray] | None = None,
51
+ pixel_counts: dict[int, int] | None = None,
52
+ ):
53
+ node_set = {int(label) for label in neighbors}
54
+ for nbrs in neighbors.values():
55
+ node_set.update(int(nbr) for nbr in np.asarray(nbrs).ravel())
56
+ if centroids is not None:
57
+ node_set.update(int(label) for label in centroids)
58
+ if pixel_counts is not None:
59
+ node_set.update(int(label) for label in pixel_counts)
60
+ nodes = np.asarray(sorted(node_set), dtype=np.int64)
61
+
62
+ edge_contacts: dict[tuple[int, int], float] = {}
63
+ edge_set: set[tuple[int, int]] = set()
64
+ for label, nbrs in neighbors.items():
65
+ label = int(label)
66
+ weights = contacts.get(label) if contacts is not None else None
67
+ if weights is None:
68
+ weights = [None] * len(nbrs)
69
+ for nbr, weight in zip(np.asarray(nbrs).ravel(), weights, strict=True):
70
+ edge = tuple(sorted((label, int(nbr))))
71
+ if edge[0] == edge[1]:
72
+ continue
73
+ edge_set.add(edge) # type: ignore (edge is 2-tuple of int, should be fine)
74
+ if contacts is not None and weight is not None:
75
+ edge_contacts.setdefault(edge, float(weight)) # type: ignore (edge is 2-tuple of int, should be fine)
76
+
77
+ edges = np.asarray(sorted(edge_set), dtype=np.int64).reshape(-1, 2)
78
+ contact_values = (
79
+ np.asarray([edge_contacts[tuple(edge)] for edge in edges], dtype=float)
80
+ if contacts is not None
81
+ else None
82
+ )
83
+ centroid_values = (
84
+ np.asarray(
85
+ [np.asarray(centroids.get(int(node), [np.nan, np.nan]), dtype=float) for node in nodes],
86
+ dtype=float,
87
+ )
88
+ if centroids is not None
89
+ else None
90
+ )
91
+ pixel_count_values = (
92
+ np.asarray([int(pixel_counts.get(int(node), 0)) for node in nodes], dtype=np.int64)
93
+ if pixel_counts is not None
94
+ else None
95
+ )
96
+ return nodes, edges, contact_values, centroid_values, pixel_count_values
97
+
98
+
99
+ def _graph_from_arrays(
100
+ nodes,
101
+ edges,
102
+ *,
103
+ contacts=None,
104
+ centroids=None,
105
+ pixel_counts=None,
106
+ ):
107
+ neighbors_lists: dict[int, list[int]] = {int(node): [] for node in np.asarray(nodes).ravel()}
108
+ contact_lists: dict[int, list[float]] | None = (
109
+ {int(node): [] for node in np.asarray(nodes).ravel()} if contacts is not None else None
110
+ )
111
+ for idx, edge in enumerate(np.asarray(edges, dtype=np.int64).reshape(-1, 2)):
112
+ a, b = int(edge[0]), int(edge[1])
113
+ neighbors_lists.setdefault(a, []).append(b)
114
+ neighbors_lists.setdefault(b, []).append(a)
115
+ if contact_lists is not None:
116
+ weight = float(np.asarray(contacts, dtype=float)[idx])
117
+ contact_lists.setdefault(a, []).append(weight)
118
+ contact_lists.setdefault(b, []).append(weight)
119
+
120
+ neighbors = {
121
+ label: np.asarray(values, dtype=np.int64)
122
+ for label, values in neighbors_lists.items()
123
+ }
124
+ contact_map = (
125
+ {label: np.asarray(values, dtype=float) for label, values in contact_lists.items()}
126
+ if contact_lists is not None
127
+ else None
128
+ )
129
+ centroid_map = (
130
+ {
131
+ int(node): np.asarray(value, dtype=float)
132
+ for node, value in zip(nodes, centroids, strict=True)
133
+ }
134
+ if centroids is not None
135
+ else None
136
+ )
137
+ pixel_count_map = (
138
+ {int(node): int(value) for node, value in zip(nodes, pixel_counts, strict=True)}
139
+ if pixel_counts is not None
140
+ else None
141
+ )
142
+ return neighbors, contact_map, centroid_map, pixel_count_map
143
+
144
+
145
+ def _json_dict_from_graph_data(neighbors, contacts, centroids, pixel_counts, metadata):
146
+ nodes, edges, contact_values, _, _ = _graph_arrays(
147
+ neighbors,
148
+ contacts=contacts,
149
+ centroids=centroids,
150
+ pixel_counts=pixel_counts,
151
+ )
152
+ node_items = []
153
+ for node in nodes:
154
+ item = {"id": int(node)}
155
+ if centroids is not None and int(node) in centroids:
156
+ item["centroid"] = np.asarray(centroids[int(node)], dtype=float).tolist()
157
+ if pixel_counts is not None and int(node) in pixel_counts:
158
+ item["pixel_count"] = int(pixel_counts[int(node)])
159
+ node_items.append(item)
160
+ edge_items = []
161
+ for idx, edge in enumerate(edges):
162
+ item = {"source": int(edge[0]), "target": int(edge[1])}
163
+ if contact_values is not None:
164
+ contact = float(contact_values[idx])
165
+ item["contact"] = contact # type: ignore (contact is types ad float for flexibility, should be fine)
166
+ item["weight"] = contact # type: ignore (contact is types ad float for flexibility, should be fine)
167
+ edge_items.append(item)
168
+ return {"nodes": node_items, "edges": edge_items, "metadata": dict(metadata)}
169
+
170
+
171
+ def _graph_data_from_json_dict(data):
172
+ nodes = np.asarray([int(node["id"]) for node in data.get("nodes", [])], dtype=np.int64)
173
+ edges = np.asarray(
174
+ [[int(edge["source"]), int(edge["target"])] for edge in data.get("edges", [])],
175
+ dtype=np.int64,
176
+ ).reshape(-1, 2)
177
+ has_contacts = any("contact" in edge for edge in data.get("edges", []))
178
+ contacts = (
179
+ np.asarray([float(edge.get("contact", edge.get("weight", 1.0))) for edge in data["edges"]])
180
+ if has_contacts
181
+ else None
182
+ )
183
+ has_centroids = any("centroid" in node for node in data.get("nodes", []))
184
+ centroids = (
185
+ np.asarray([node.get("centroid", [np.nan, np.nan]) for node in data["nodes"]], dtype=float)
186
+ if has_centroids
187
+ else None
188
+ )
189
+ has_pixel_counts = any("pixel_count" in node for node in data.get("nodes", []))
190
+ pixel_counts = (
191
+ np.asarray([int(node.get("pixel_count", 0)) for node in data["nodes"]], dtype=np.int64)
192
+ if has_pixel_counts
193
+ else None
194
+ )
195
+ neighbors, contact_map, centroid_map, pixel_count_map = _graph_from_arrays(
196
+ nodes,
197
+ edges,
198
+ contacts=contacts,
199
+ centroids=centroids,
200
+ pixel_counts=pixel_counts,
201
+ )
202
+ return LabelGraphData(
203
+ neighbors,
204
+ contact_map,
205
+ centroid_map,
206
+ pixel_count_map,
207
+ dict(data.get("metadata", {})),
208
+ )
209
+
210
+
211
+ def _graph_to_networkx(neighbors, contacts=None, centroids=None, pixel_counts=None, metadata=None):
212
+ nx = optional_import(
213
+ "networkx",
214
+ extra="graph-standard",
215
+ feature="GraphML/GEXF graph I/O",
216
+ package_name="networkx",
217
+ )
218
+ graph = nx.Graph()
219
+ nodes, edges, contact_values, _, _ = _graph_arrays(
220
+ neighbors,
221
+ contacts=contacts,
222
+ centroids=centroids,
223
+ pixel_counts=pixel_counts,
224
+ )
225
+ for node in nodes:
226
+ attrs = {}
227
+ if centroids is not None and int(node) in centroids:
228
+ cy, cx = np.asarray(centroids[int(node)], dtype=float)
229
+ attrs.update({"centroid_y": float(cy), "centroid_x": float(cx)})
230
+ if pixel_counts is not None and int(node) in pixel_counts:
231
+ attrs["pixel_count"] = int(pixel_counts[int(node)])
232
+ graph.add_node(str(int(node)), **attrs)
233
+ for idx, edge in enumerate(edges):
234
+ attrs = {}
235
+ if contact_values is not None:
236
+ contact = float(contact_values[idx])
237
+ attrs.update({"contact": contact, "weight": contact})
238
+ graph.add_edge(str(int(edge[0])), str(int(edge[1])), **attrs)
239
+ if metadata:
240
+ graph.graph["metadata"] = json.dumps(metadata, default=_json_default)
241
+ return graph
242
+
243
+
244
+ def _graph_from_networkx(graph):
245
+ nodes = np.asarray([int(node) for node in graph.nodes], dtype=np.int64)
246
+ edges = np.asarray([[int(a), int(b)] for a, b in graph.edges], dtype=np.int64).reshape(-1, 2)
247
+ has_contacts = any(
248
+ "contact" in data or "weight" in data
249
+ for _, _, data in graph.edges(data=True)
250
+ )
251
+ contacts = (
252
+ np.asarray(
253
+ [
254
+ float(data.get("contact", data.get("weight", 1.0)))
255
+ for _, _, data in graph.edges(data=True)
256
+ ],
257
+ dtype=float,
258
+ )
259
+ if has_contacts
260
+ else None
261
+ )
262
+ has_centroids = any(
263
+ "centroid_y" in data and "centroid_x" in data
264
+ for _, data in graph.nodes(data=True)
265
+ )
266
+ centroids = (
267
+ np.asarray(
268
+ [
269
+ [float(data.get("centroid_y", np.nan)), float(data.get("centroid_x", np.nan))]
270
+ for _, data in graph.nodes(data=True)
271
+ ],
272
+ dtype=float,
273
+ )
274
+ if has_centroids
275
+ else None
276
+ )
277
+ has_pixel_counts = any("pixel_count" in data for _, data in graph.nodes(data=True))
278
+ pixel_counts = (
279
+ np.asarray(
280
+ [int(data.get("pixel_count", 0)) for _, data in graph.nodes(data=True)],
281
+ dtype=np.int64,
282
+ )
283
+ if has_pixel_counts
284
+ else None
285
+ )
286
+ metadata_raw = graph.graph.get("metadata", "{}")
287
+ try:
288
+ metadata = json.loads(metadata_raw)
289
+ except TypeError:
290
+ metadata = {}
291
+ neighbors, contact_map, centroid_map, pixel_count_map = _graph_from_arrays(
292
+ nodes,
293
+ edges,
294
+ contacts=contacts,
295
+ centroids=centroids,
296
+ pixel_counts=pixel_counts,
297
+ )
298
+ return LabelGraphData(neighbors, contact_map, centroid_map, pixel_count_map, metadata)
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import import_module
4
+ from typing import Any
5
+
6
+
7
+ def optional_import(
8
+ module_name: str,
9
+ *,
10
+ extra: str,
11
+ feature: str,
12
+ package_name: str | None = None,
13
+ ) -> Any:
14
+ """Import an optional dependency or raise a clear installation hint."""
15
+ display_name = package_name or module_name
16
+ try:
17
+ return import_module(module_name)
18
+ except ImportError as exc:
19
+ install_hint = f"`pip install labelimage-tools[{extra}]`"
20
+ if extra != "all":
21
+ install_hint += " or `pip install labelimage-tools[all]`"
22
+ raise ImportError(
23
+ f"{feature} requires the optional dependency `{display_name}`. "
24
+ f"Install it with {install_hint} "
25
+ f"or install `{display_name}` directly."
26
+ ) from exc