Lewislou commited on
Commit
0ce1ebe
1 Parent(s): f065752

Upload 27 files

Browse files
stardist_pkg/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import absolute_import, print_function
2
+
3
+ import warnings
4
+ def format_warning(message, category, filename, lineno, line=''):
5
+ import pathlib
6
+ return f"{pathlib.Path(filename).name} ({lineno}): {message}\n"
7
+ warnings.formatwarning = format_warning
8
+ del warnings
9
+
10
+ from .version import __version__
11
+
12
+ # TODO: which functions to expose here? all?
13
+ from .nms import non_maximum_suppression
14
+ from .utils import edt_prob, fill_label_holes, sample_points, calculate_extents, export_imagej_rois, gputools_available
15
+ from .geometry import star_dist, polygons_to_label, relabel_image_stardist, ray_angles, dist_to_coord
16
+ from .sample_patches import sample_patches
17
+ from .bioimageio_utils import export_bioimageio, import_bioimageio
18
+
19
+ def _py_deprecation(ver_python=(3,6), ver_stardist='0.9.0'):
20
+ import sys
21
+ from distutils.version import LooseVersion
22
+ if sys.version_info[:2] == ver_python and LooseVersion(__version__) < LooseVersion(ver_stardist):
23
+ print(f"You are using Python {ver_python[0]}.{ver_python[1]}, which will no longer be supported in StarDist {ver_stardist}.\n"
24
+ f"→ Please upgrade to Python {ver_python[0]}.{ver_python[1]+1} or later.", file=sys.stderr, flush=True)
25
+ _py_deprecation()
26
+ del _py_deprecation
stardist_pkg/__pycache__/__init__.cpython-37.pyc ADDED
Binary file (1.62 kB). View file
 
stardist_pkg/__pycache__/big.cpython-37.pyc ADDED
Binary file (20.7 kB). View file
 
stardist_pkg/__pycache__/bioimageio_utils.cpython-37.pyc ADDED
Binary file (15.2 kB). View file
 
stardist_pkg/__pycache__/matching.cpython-37.pyc ADDED
Binary file (16.9 kB). View file
 
stardist_pkg/__pycache__/nms.cpython-37.pyc ADDED
Binary file (9.59 kB). View file
 
stardist_pkg/__pycache__/sample_patches.cpython-37.pyc ADDED
Binary file (4.22 kB). View file
 
stardist_pkg/__pycache__/utils.cpython-37.pyc ADDED
Binary file (15.4 kB). View file
 
stardist_pkg/__pycache__/version.cpython-37.pyc ADDED
Binary file (199 Bytes). View file
 
stardist_pkg/big.py ADDED
@@ -0,0 +1,601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import warnings
3
+ import math
4
+ from tqdm import tqdm
5
+ from skimage.measure import regionprops
6
+ from skimage.draw import polygon
7
+ from csbdeep.utils import _raise, axes_check_and_normalize, axes_dict
8
+ from itertools import product
9
+
10
+
11
+
12
+
13
+ OBJECT_KEYS = set(('prob', 'points', 'coord', 'dist', 'class_prob', 'class_id'))
14
+ COORD_KEYS = set(('points', 'coord'))
15
+
16
+
17
+
18
+ class Block:
19
+ """One-dimensional block as part of a chain.
20
+
21
+ There are no explicit start and end positions. Instead, each block is
22
+ aware of its predecessor and successor and derives such things (recursively)
23
+ based on its neighbors.
24
+
25
+ Blocks overlap with one another (at least min_overlap + 2*context) and
26
+ have a read region (the entire block) and a write region (ignoring context).
27
+ Given a query interval, Block.is_responsible will return true for only one
28
+ block of a chain (or raise an exception if the interval is larger than
29
+ min_overlap or even the entire block without context).
30
+
31
+ """
32
+ def __init__(self, size, min_overlap, context, pred):
33
+ self.size = int(size)
34
+ self.min_overlap = int(min_overlap)
35
+ self.context = int(context)
36
+ self.pred = pred
37
+ self.succ = None
38
+ assert 0 <= self.min_overlap + 2*self.context < self.size
39
+ self.stride = self.size - (self.min_overlap + 2*self.context)
40
+ self._start = 0
41
+ self._frozen = False
42
+
43
+ @property
44
+ def start(self):
45
+ return self._start if (self.frozen or self.at_begin) else self.pred.succ_start
46
+
47
+ @property
48
+ def end(self):
49
+ return self.start + self.size
50
+
51
+ @property
52
+ def succ_start(self):
53
+ return self.start + self.stride
54
+
55
+ def add_succ(self):
56
+ assert self.succ is None and not self.frozen
57
+ self.succ = Block(self.size, self.min_overlap, self.context, self)
58
+ return self.succ
59
+
60
+ def decrease_stride(self, amount):
61
+ amount = int(amount)
62
+ assert 0 <= amount < self.stride and not self.frozen
63
+ self.stride -= amount
64
+
65
+ def freeze(self):
66
+ """Call on first block to freeze entire chain (after construction is done)"""
67
+ assert not self.frozen and (self.at_begin or self.pred.frozen)
68
+ self._start = self.start
69
+ self._frozen = True
70
+ if not self.at_end:
71
+ self.succ.freeze()
72
+
73
+ @property
74
+ def slice_read(self):
75
+ return slice(self.start, self.end)
76
+
77
+ @property
78
+ def slice_crop_context(self):
79
+ """Crop context relative to read region"""
80
+ return slice(self.context_start, self.size - self.context_end)
81
+
82
+ @property
83
+ def slice_write(self):
84
+ return slice(self.start + self.context_start, self.end - self.context_end)
85
+
86
+ def is_responsible(self, bbox):
87
+ """Responsibility for query interval bbox, which is assumed to be smaller than min_overlap.
88
+
89
+ If the assumption is met, only one block of a chain will return true.
90
+ If violated, one or more blocks of a chain may raise a NotFullyVisible exception.
91
+ The exception will have an argument that is
92
+ False if bbox is larger than min_overlap, and
93
+ True if bbox is even larger than the entire block without context.
94
+
95
+ bbox: (int,int)
96
+ 1D bounding box interval with coordinates relative to size without context
97
+
98
+ """
99
+ bmin, bmax = bbox
100
+
101
+ r_start = 0 if self.at_begin else (self.pred.overlap - self.pred.context_end - self.context_start)
102
+ r_end = self.size - self.context_start - self.context_end
103
+ assert 0 <= bmin < bmax <= r_end
104
+
105
+ # assert not (bmin == 0 and bmax >= r_start and not self.at_begin), [(r_start,r_end), bbox, self]
106
+
107
+ if bmin == 0 and bmax >= r_start:
108
+ if bmax == r_end:
109
+ # object spans the entire block, i.e. is probably larger than size (minus the context)
110
+ raise NotFullyVisible(True)
111
+ if not self.at_begin:
112
+ # object spans the entire overlap region, i.e. is only partially visible here and also by the predecessor block
113
+ raise NotFullyVisible(False)
114
+
115
+ # object ends before responsible region start
116
+ if bmax < r_start: return False
117
+ # object touches the end of the responsible region (only take if at end)
118
+ if bmax == r_end and not self.at_end: return False
119
+ return True
120
+
121
+ # ------------------------
122
+
123
+ @property
124
+ def frozen(self):
125
+ return self._frozen
126
+
127
+ @property
128
+ def at_begin(self):
129
+ return self.pred is None
130
+
131
+ @property
132
+ def at_end(self):
133
+ return self.succ is None
134
+
135
+ @property
136
+ def overlap(self):
137
+ return self.size - self.stride
138
+
139
+ @property
140
+ def context_start(self):
141
+ return 0 if self.at_begin else self.context
142
+
143
+ @property
144
+ def context_end(self):
145
+ return 0 if self.at_end else self.context
146
+
147
+ def __repr__(self):
148
+ shared = f'{self.start:03}:{self.end:03}'
149
+ shared += f', size={self.context_start}-{self.size-self.context_start-self.context_end}-{self.context_end}'
150
+ if self.at_end:
151
+ return f'{self.__class__.__name__}({shared})'
152
+ else:
153
+ return f'{self.__class__.__name__}({shared}, overlap={self.overlap}/{self.overlap-self.context_start-self.context_end})'
154
+
155
+ @property
156
+ def chain(self):
157
+ blocks = [self]
158
+ while not blocks[-1].at_end:
159
+ blocks.append(blocks[-1].succ)
160
+ return blocks
161
+
162
+ def __iter__(self):
163
+ return iter(self.chain)
164
+
165
+ # ------------------------
166
+
167
+ @staticmethod
168
+ def cover(size, block_size, min_overlap, context, grid=1, verbose=True):
169
+ """Return chain of grid-aligned blocks to cover the interval [0,size].
170
+
171
+ Parameters block_size, min_overlap, and context will be used
172
+ for all blocks of the chain. Only the size of the last block
173
+ may differ.
174
+
175
+ Except for the last block, start and end positions of all blocks will
176
+ be multiples of grid. To that end, the provided block parameters may
177
+ be increased to achieve that.
178
+
179
+ Note that parameters must be chosen such that the write regions of only
180
+ neighboring blocks are overlapping.
181
+
182
+ """
183
+ assert 0 <= min_overlap+2*context < block_size <= size
184
+ assert 0 < grid <= block_size
185
+ block_size = _grid_divisible(grid, block_size, name='block_size', verbose=verbose)
186
+ min_overlap = _grid_divisible(grid, min_overlap, name='min_overlap', verbose=verbose)
187
+ context = _grid_divisible(grid, context, name='context', verbose=verbose)
188
+
189
+ # allow size not to be divisible by grid
190
+ size_orig = size
191
+ size = _grid_divisible(grid, size, name='size', verbose=False)
192
+
193
+ # divide all sizes by grid
194
+ assert all(v % grid == 0 for v in (size, block_size, min_overlap, context))
195
+ size //= grid
196
+ block_size //= grid
197
+ min_overlap //= grid
198
+ context //= grid
199
+
200
+ # compute cover in grid-multiples
201
+ t = first = Block(block_size, min_overlap, context, None)
202
+ while t.end < size:
203
+ t = t.add_succ()
204
+ last = t
205
+
206
+ # [print(t) for t in first]
207
+
208
+ # move blocks around to make it fit
209
+ excess = last.end - size
210
+ t = first
211
+ while excess > 0:
212
+ t.decrease_stride(1)
213
+ excess -= 1
214
+ t = t.succ
215
+ if (t == last): t = first
216
+
217
+ # make a copy of the cover and multiply sizes by grid
218
+ if grid > 1:
219
+ size *= grid
220
+ block_size *= grid
221
+ min_overlap *= grid
222
+ context *= grid
223
+ #
224
+ _t = _first = first
225
+ t = first = Block(block_size, min_overlap, context, None)
226
+ t.stride = _t.stride*grid
227
+ while not _t.at_end:
228
+ _t = _t.succ
229
+ t = t.add_succ()
230
+ t.stride = _t.stride*grid
231
+ last = t
232
+
233
+ # change size of last block
234
+ # will be padded internally to the same size
235
+ # as the others by model.predict_instances
236
+ size_delta = size - size_orig
237
+ last.size -= size_delta
238
+ assert 0 <= size_delta < grid
239
+
240
+ # for efficiency (to not determine starts recursively from now on)
241
+ first.freeze()
242
+
243
+ blocks = first.chain
244
+
245
+ # sanity checks
246
+ assert first.start == 0 and last.end == size_orig
247
+ assert all(t.overlap-2*context >= min_overlap for t in blocks if t != last)
248
+ assert all(t.start % grid == 0 and t.end % grid == 0 for t in blocks if t != last)
249
+ # print(); [print(t) for t in first]
250
+
251
+ # only neighboring blocks should be overlapping
252
+ if len(blocks) >= 3:
253
+ for t in blocks[:-2]:
254
+ assert t.slice_write.stop <= t.succ.succ.slice_write.start
255
+
256
+ return blocks
257
+
258
+
259
+
260
+ class BlockND:
261
+ """N-dimensional block.
262
+
263
+ Each BlockND simply consists of a 1-dimensional Block per axis and also
264
+ has an id (which should be unique). The n-dimensional region represented
265
+ by each BlockND is the intersection of all 1D Blocks per axis.
266
+
267
+ Also see `Block`.
268
+
269
+ """
270
+ def __init__(self, id, blocks, axes):
271
+ self.id = id
272
+ self.blocks = tuple(blocks)
273
+ self.axes = axes_check_and_normalize(axes, length=len(self.blocks))
274
+ self.axis_to_block = dict(zip(self.axes,self.blocks))
275
+
276
+ def blocks_for_axes(self, axes=None):
277
+ axes = self.axes if axes is None else axes_check_and_normalize(axes)
278
+ return tuple(self.axis_to_block[a] for a in axes)
279
+
280
+ def slice_read(self, axes=None):
281
+ return tuple(t.slice_read for t in self.blocks_for_axes(axes))
282
+
283
+ def slice_crop_context(self, axes=None):
284
+ return tuple(t.slice_crop_context for t in self.blocks_for_axes(axes))
285
+
286
+ def slice_write(self, axes=None):
287
+ return tuple(t.slice_write for t in self.blocks_for_axes(axes))
288
+
289
+ def read(self, x, axes=None):
290
+ """Read block "read region" from x (numpy.ndarray or similar)"""
291
+ return x[self.slice_read(axes)]
292
+
293
+ def crop_context(self, labels, axes=None):
294
+ return labels[self.slice_crop_context(axes)]
295
+
296
+ def write(self, x, labels, axes=None):
297
+ """Write (only entries > 0 of) labels to block "write region" of x (numpy.ndarray or similar)"""
298
+ s = self.slice_write(axes)
299
+ mask = labels > 0
300
+ # x[s][mask] = labels[mask] # doesn't work with zarr
301
+ region = x[s]
302
+ region[mask] = labels[mask]
303
+ x[s] = region
304
+
305
+ def is_responsible(self, slices, axes=None):
306
+ return all(t.is_responsible((s.start,s.stop)) for t,s in zip(self.blocks_for_axes(axes),slices))
307
+
308
+ def __repr__(self):
309
+ slices = ','.join(f'{a}={t.start:03}:{t.end:03}' for t,a in zip(self.blocks,self.axes))
310
+ return f'{self.__class__.__name__}({self.id}|{slices})'
311
+
312
+ def __iter__(self):
313
+ return iter(self.blocks)
314
+
315
+ # ------------------------
316
+
317
+ def filter_objects(self, labels, polys, axes=None):
318
+ """Filter out objects that block is not responsible for.
319
+
320
+ Given label image 'labels' and dictionary 'polys' of polygon/polyhedron objects,
321
+ only retain those objects that this block is responsible for.
322
+
323
+ This function will return a pair (labels, polys) of the modified label image and dictionary.
324
+ It will raise a RuntimeError if an object is found in the overlap area
325
+ of neighboring blocks that violates the assumption to be smaller than 'min_overlap'.
326
+
327
+ If parameter 'polys' is None, only the filtered label image will be returned.
328
+
329
+ Notes
330
+ -----
331
+ - Important: It is assumed that the object label ids in 'labels' and
332
+ the entries in 'polys' are sorted in the same way.
333
+ - Does not modify 'labels' and 'polys', but returns modified copies.
334
+
335
+ Example
336
+ -------
337
+ >>> labels, polys = model.predict_instances(block.read(img))
338
+ >>> labels = block.crop_context(labels)
339
+ >>> labels, polys = block.filter_objects(labels, polys)
340
+
341
+ """
342
+ # TODO: option to update labels in-place
343
+ assert np.issubdtype(labels.dtype, np.integer)
344
+ ndim = len(self.blocks_for_axes(axes))
345
+ assert ndim in (2,3)
346
+ assert labels.ndim == ndim and labels.shape == tuple(s.stop-s.start for s in self.slice_crop_context(axes))
347
+
348
+ labels_filtered = np.zeros_like(labels)
349
+ # problem_ids = []
350
+ for r in regionprops(labels):
351
+ slices = tuple(slice(r.bbox[i],r.bbox[i+labels.ndim]) for i in range(labels.ndim))
352
+ try:
353
+ if self.is_responsible(slices, axes):
354
+ labels_filtered[slices][r.image] = r.label
355
+ except NotFullyVisible as e:
356
+ # shape_block_write = tuple(s.stop-s.start for s in self.slice_write(axes))
357
+ shape_object = tuple(s.stop-s.start for s in slices)
358
+ shape_min_overlap = tuple(t.min_overlap for t in self.blocks_for_axes(axes))
359
+ raise RuntimeError(f"Found object of shape {shape_object}, which violates the assumption of being smaller than 'min_overlap' {shape_min_overlap}. Increase 'min_overlap' to avoid this problem.")
360
+
361
+ # if e.args[0]: # object larger than block write region
362
+ # assert any(o >= b for o,b in zip(shape_object,shape_block_write))
363
+ # # problem, since this object will probably be saved by another block too
364
+ # raise RuntimeError(f"Found object of shape {shape_object}, larger than an entire block's write region of shape {shape_block_write}. Increase 'block_size' to avoid this problem.")
365
+ # # print("found object larger than 'block_size'")
366
+ # else:
367
+ # assert any(o >= b for o,b in zip(shape_object,shape_min_overlap))
368
+ # # print("found object larger than 'min_overlap'")
369
+
370
+ # # keep object, because will be dealt with later, i.e.
371
+ # # render the poly again into the label image, but this is not
372
+ # # ideal since the assumption is that the object outside that
373
+ # # region is not reliable because it's in the context
374
+ # labels_filtered[slices][r.image] = r.label
375
+ # problem_ids.append(r.label)
376
+
377
+ if polys is None:
378
+ # assert len(problem_ids) == 0
379
+ return labels_filtered
380
+ else:
381
+ # it is assumed that ids in 'labels' map to entries in 'polys'
382
+ assert isinstance(polys,dict) and any(k in polys for k in COORD_KEYS)
383
+ filtered_labels = np.unique(labels_filtered)
384
+ filtered_ind = [i-1 for i in filtered_labels if i > 0]
385
+ polys_out = {k: (v[filtered_ind] if k in OBJECT_KEYS else v) for k,v in polys.items()}
386
+ for k in COORD_KEYS:
387
+ if k in polys_out.keys():
388
+ polys_out[k] = self.translate_coordinates(polys_out[k], axes=axes)
389
+
390
+ return labels_filtered, polys_out#, tuple(problem_ids)
391
+
392
+ def translate_coordinates(self, coordinates, axes=None):
393
+ """Translate local block coordinates (of read region) to global ones based on block position"""
394
+ ndim = len(self.blocks_for_axes(axes))
395
+ assert isinstance(coordinates, np.ndarray) and coordinates.ndim >= 2 and coordinates.shape[1] == ndim
396
+ start = [s.start for s in self.slice_read(axes)]
397
+ shape = tuple(1 if d!=1 else ndim for d in range(coordinates.ndim))
398
+ start = np.array(start).reshape(shape)
399
+ return coordinates + start
400
+
401
+ # ------------------------
402
+
403
+ @staticmethod
404
+ def cover(shape, axes, block_size, min_overlap, context, grid=1):
405
+ """Return grid-aligned n-dimensional blocks to cover region
406
+ of the given shape with axes semantics.
407
+
408
+ Parameters block_size, min_overlap, and context can be different per
409
+ dimension/axis (if provided as list) or the same (if provided as
410
+ scalar value).
411
+
412
+ Also see `Block.cover`.
413
+
414
+ """
415
+ shape = tuple(shape)
416
+ n = len(shape)
417
+ axes = axes_check_and_normalize(axes, length=n)
418
+ if np.isscalar(block_size): block_size = n*[block_size]
419
+ if np.isscalar(min_overlap): min_overlap = n*[min_overlap]
420
+ if np.isscalar(context): context = n*[context]
421
+ if np.isscalar(grid): grid = n*[grid]
422
+ assert n == len(block_size) == len(min_overlap) == len(context) == len(grid)
423
+
424
+ # compute cover for each dimension
425
+ cover_1d = [Block.cover(*args) for args in zip(shape, block_size, min_overlap, context, grid)]
426
+ # return cover as Cartesian product of 1-dimensional blocks
427
+ return tuple(BlockND(i,blocks,axes) for i,blocks in enumerate(product(*cover_1d)))
428
+
429
+
430
+
431
+ class Polygon:
432
+
433
+ def __init__(self, coord, bbox=None, shape_max=None):
434
+ self.bbox = self.coords_bbox(coord, shape_max=shape_max) if bbox is None else bbox
435
+ self.coord = coord - np.array([r[0] for r in self.bbox]).reshape(2,1)
436
+ self.slice = tuple(slice(*r) for r in self.bbox)
437
+ self.shape = tuple(r[1]-r[0] for r in self.bbox)
438
+ rr,cc = polygon(*self.coord, self.shape)
439
+ self.mask = np.zeros(self.shape, bool)
440
+ self.mask[rr,cc] = True
441
+
442
+ @staticmethod
443
+ def coords_bbox(*coords, shape_max=None):
444
+ assert all(isinstance(c, np.ndarray) and c.ndim==2 and c.shape[0]==2 for c in coords)
445
+ if shape_max is None:
446
+ shape_max = (np.inf, np.inf)
447
+ coord = np.concatenate(coords, axis=1)
448
+ mins = np.maximum(0, np.floor(np.min(coord,axis=1))).astype(int)
449
+ maxs = np.minimum(shape_max, np.ceil (np.max(coord,axis=1))).astype(int)
450
+ return tuple(zip(tuple(mins),tuple(maxs)))
451
+
452
+
453
+
454
+ class Polyhedron:
455
+
456
+ def __init__(self, dist, origin, rays, bbox=None, shape_max=None):
457
+ self.bbox = self.coords_bbox((dist, origin), rays=rays, shape_max=shape_max) if bbox is None else bbox
458
+ self.slice = tuple(slice(*r) for r in self.bbox)
459
+ self.shape = tuple(r[1]-r[0] for r in self.bbox)
460
+ _origin = origin.reshape(1,3) - np.array([r[0] for r in self.bbox]).reshape(1,3)
461
+ self.mask = polyhedron_to_label(dist[np.newaxis], _origin, rays, shape=self.shape, verbose=False).astype(bool)
462
+
463
+ @staticmethod
464
+ def coords_bbox(*dist_origin, rays, shape_max=None):
465
+ dists, points = zip(*dist_origin)
466
+ assert all(isinstance(d, np.ndarray) and d.ndim==1 and len(d)==len(rays) for d in dists)
467
+ assert all(isinstance(p, np.ndarray) and p.ndim==1 and len(p)==3 for p in points)
468
+ dists, points, verts = np.stack(dists)[...,np.newaxis], np.stack(points)[:,np.newaxis], rays.vertices[np.newaxis]
469
+ coord = dists * verts + points
470
+ coord = np.concatenate(coord, axis=0)
471
+ if shape_max is None:
472
+ shape_max = (np.inf, np.inf, np.inf)
473
+ mins = np.maximum(0, np.floor(np.min(coord,axis=0))).astype(int)
474
+ maxs = np.minimum(shape_max, np.ceil (np.max(coord,axis=0))).astype(int)
475
+ return tuple(zip(tuple(mins),tuple(maxs)))
476
+
477
+
478
+
479
+ # def repaint_labels(output, labels, polys, show_progress=True):
480
+ # """Repaint object instances in correct order based on probability scores.
481
+
482
+ # Does modify 'output' and 'polys' in-place, but will only write sparsely to 'output' where needed.
483
+
484
+ # output: numpy.ndarray or similar
485
+ # Label image (integer-valued)
486
+ # labels: iterable of int
487
+ # List of integer label ids that occur in output
488
+ # polys: dict
489
+ # Dictionary of polygon/polyhedra properties.
490
+ # Assumption is that the label id (-1) corresponds to the index in the polys dict
491
+
492
+ # """
493
+ # assert output.ndim in (2,3)
494
+
495
+ # if show_progress:
496
+ # labels = tqdm(labels, leave=True)
497
+
498
+ # labels_eliminated = set()
499
+
500
+ # # TODO: inelegant to have so much duplicated code here
501
+ # if output.ndim == 2:
502
+ # coord = lambda i: polys['coord'][i-1]
503
+ # prob = lambda i: polys['prob'][i-1]
504
+
505
+ # for i in labels:
506
+ # if i in labels_eliminated: continue
507
+ # poly_i = Polygon(coord(i), shape_max=output.shape)
508
+
509
+ # # find all labels that overlap with i (including i)
510
+ # overlapping = set(np.unique(output[poly_i.slice][poly_i.mask])) - {0}
511
+ # assert i in overlapping
512
+ # # compute bbox union to find area to crop/replace in large output label image
513
+ # bbox_union = Polygon.coords_bbox(*[coord(j) for j in overlapping], shape_max=output.shape)
514
+
515
+ # # crop out label i, including the region that include all overlapping labels
516
+ # poly_i = Polygon(coord(i), bbox=bbox_union)
517
+ # mask = poly_i.mask.copy()
518
+
519
+ # # remove pixels from mask that belong to labels with higher probability
520
+ # for j in [j for j in overlapping if prob(j) > prob(i)]:
521
+ # mask[ Polygon(coord(j), bbox=bbox_union).mask ] = False
522
+
523
+ # crop = output[poly_i.slice]
524
+ # crop[crop==i] = 0 # delete all remnants of i in crop
525
+ # crop[mask] = i # paint i where mask still active
526
+
527
+ # labels_remaining = set(np.unique(output[poly_i.slice][poly_i.mask])) - {0}
528
+ # labels_eliminated.update(overlapping - labels_remaining)
529
+ # else:
530
+
531
+ # dist = lambda i: polys['dist'][i-1]
532
+ # origin = lambda i: polys['points'][i-1]
533
+ # prob = lambda i: polys['prob'][i-1]
534
+ # rays = polys['rays']
535
+
536
+ # for i in labels:
537
+ # if i in labels_eliminated: continue
538
+ # poly_i = Polyhedron(dist(i), origin(i), rays, shape_max=output.shape)
539
+
540
+ # # find all labels that overlap with i (including i)
541
+ # overlapping = set(np.unique(output[poly_i.slice][poly_i.mask])) - {0}
542
+ # assert i in overlapping
543
+ # # compute bbox union to find area to crop/replace in large output label image
544
+ # bbox_union = Polyhedron.coords_bbox(*[(dist(j),origin(j)) for j in overlapping], rays=rays, shape_max=output.shape)
545
+
546
+ # # crop out label i, including the region that include all overlapping labels
547
+ # poly_i = Polyhedron(dist(i), origin(i), rays, bbox=bbox_union)
548
+ # mask = poly_i.mask.copy()
549
+
550
+ # # remove pixels from mask that belong to labels with higher probability
551
+ # for j in [j for j in overlapping if prob(j) > prob(i)]:
552
+ # mask[ Polyhedron(dist(j), origin(j), rays, bbox=bbox_union).mask ] = False
553
+
554
+ # crop = output[poly_i.slice]
555
+ # crop[crop==i] = 0 # delete all remnants of i in crop
556
+ # crop[mask] = i # paint i where mask still active
557
+
558
+ # labels_remaining = set(np.unique(output[poly_i.slice][poly_i.mask])) - {0}
559
+ # labels_eliminated.update(overlapping - labels_remaining)
560
+
561
+ # if len(labels_eliminated) > 0:
562
+ # ind = [i-1 for i in labels_eliminated]
563
+ # for k,v in polys.items():
564
+ # if k in OBJECT_KEYS:
565
+ # polys[k] = np.delete(v, ind, axis=0)
566
+
567
+
568
+
569
+ ############
570
+
571
+
572
+
573
+ def predict_big(model, *args, **kwargs):
574
+ from .models import StarDist2D, StarDist3D
575
+ if isinstance(model,(StarDist2D,StarDist3D)):
576
+ dst = model.__class__.__name__
577
+ else:
578
+ dst = '{StarDist2D, StarDist3D}'
579
+ raise RuntimeError(f"This function has moved to {dst}.predict_instances_big.")
580
+
581
+
582
+
583
+ class NotFullyVisible(Exception):
584
+ pass
585
+
586
+
587
+
588
+ def _grid_divisible(grid, size, name=None, verbose=True):
589
+ if size % grid == 0:
590
+ return size
591
+ _size = size
592
+ size = math.ceil(size / grid) * grid
593
+ if bool(verbose):
594
+ print(f"{verbose if isinstance(verbose,str) else ''}increasing '{'value' if name is None else name}' from {_size} to {size} to be evenly divisible by {grid} (grid)", flush=True)
595
+ assert size % grid == 0
596
+ return size
597
+
598
+
599
+
600
+ # def render_polygons(polys, shape):
601
+ # return polygons_to_label_coord(polys['coord'], shape=shape)
stardist_pkg/bioimageio_utils.py ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from pkg_resources import get_distribution
3
+ from zipfile import ZipFile
4
+ import numpy as np
5
+ import tempfile
6
+ from distutils.version import LooseVersion
7
+ from csbdeep.utils import axes_check_and_normalize, normalize, _raise
8
+
9
+
10
+ DEEPIMAGEJ_MACRO = \
11
+ """
12
+ //*******************************************************************
13
+ // Date: July-2021
14
+ // Credits: StarDist, DeepImageJ
15
+ // URL:
16
+ // https://github.com/stardist/stardist
17
+ // https://deepimagej.github.io/deepimagej
18
+ // This macro was adapted from
19
+ // https://github.com/deepimagej/imagej-macros/blob/648caa867f6ccb459649d4d3799efa1e2e0c5204/StarDist2D_Post-processing.ijm
20
+ // Please cite the respective contributions when using this code.
21
+ //*******************************************************************
22
+ // Macro to run StarDist postprocessing on 2D images.
23
+ // StarDist and deepImageJ plugins need to be installed.
24
+ // The macro assumes that the image to process is a stack in which
25
+ // the first channel corresponds to the object probability map
26
+ // and the remaining channels are the radial distances from each
27
+ // pixel to the object boundary.
28
+ //*******************************************************************
29
+
30
+ // Get the name of the image to call it
31
+ getDimensions(width, height, channels, slices, frames);
32
+ name=getTitle();
33
+
34
+ probThresh={probThresh};
35
+ nmsThresh={nmsThresh};
36
+
37
+ // Isolate the detection probability scores
38
+ run("Make Substack...", "channels=1");
39
+ rename("scores");
40
+
41
+ // Isolate the oriented distances
42
+ run("Fire");
43
+ selectWindow(name);
44
+ run("Delete Slice", "delete=channel");
45
+ selectWindow(name);
46
+ run("Properties...", "channels=" + maxOf(channels, slices) - 1 + " slices=1 frames=1 pixel_width=1.0000 pixel_height=1.0000 voxel_depth=1.0000");
47
+ rename("distances");
48
+ run("royal");
49
+
50
+ // Run StarDist plugin
51
+ run("Command From Macro", "command=[de.csbdresden.stardist.StarDist2DNMS], args=['prob':'scores', 'dist':'distances', 'probThresh':'" + probThresh + "', 'nmsThresh':'" + nmsThresh + "', 'outputType':'Both', 'excludeBoundary':'2', 'roiPosition':'Stack', 'verbose':'false'], process=[false]");
52
+ """
53
+
54
+
55
+ def _import(error=True):
56
+ try:
57
+ from importlib_metadata import metadata
58
+ from bioimageio.core.build_spec import build_model # type: ignore
59
+ import xarray as xr
60
+ import bioimageio.core # type: ignore
61
+ except ImportError:
62
+ if error:
63
+ raise RuntimeError(
64
+ "Required libraries are missing for bioimage.io model export.\n"
65
+ "Please install StarDist as follows: pip install 'stardist[bioimageio]'\n"
66
+ "(You do not need to uninstall StarDist first.)"
67
+ )
68
+ else:
69
+ return None
70
+ return metadata, build_model, bioimageio.core, xr
71
+
72
+
73
+ def _create_stardist_dependencies(outdir):
74
+ from ruamel.yaml import YAML
75
+ from tensorflow import __version__ as tf_version
76
+ from . import __version__ as stardist_version
77
+ pkg_info = get_distribution("stardist")
78
+ # dependencies that start with the name "bioimageio" will be added as conda dependencies
79
+ reqs_conda = [str(req) for req in pkg_info.requires(extras=['bioimageio']) if str(req).startswith('bioimageio')]
80
+ # only stardist and tensorflow as pip dependencies
81
+ tf_major, tf_minor = LooseVersion(tf_version).version[:2]
82
+ reqs_pip = (f"stardist>={stardist_version}", f"tensorflow>={tf_major}.{tf_minor},<{tf_major+1}")
83
+ # conda environment
84
+ env = dict(
85
+ name = 'stardist',
86
+ channels = ['defaults', 'conda-forge'],
87
+ dependencies = [
88
+ ('python>=3.7,<3.8' if tf_major == 1 else 'python>=3.7'),
89
+ *reqs_conda,
90
+ 'pip', {'pip': reqs_pip},
91
+ ],
92
+ )
93
+ yaml = YAML(typ='safe')
94
+ path = outdir / "environment.yaml"
95
+ with open(path, "w") as f:
96
+ yaml.dump(env, f)
97
+ return f"conda:{path}"
98
+
99
+
100
+ def _create_stardist_doc(outdir):
101
+ doc_path = outdir / "README.md"
102
+ text = (
103
+ "# StarDist Model\n"
104
+ "This is a model for object detection with star-convex shapes.\n"
105
+ "Please see the [StarDist repository](https://github.com/stardist/stardist) for details."
106
+ )
107
+ with open(doc_path, "w") as f:
108
+ f.write(text)
109
+ return doc_path
110
+
111
+
112
+ def _get_stardist_metadata(outdir, model):
113
+ metadata, *_ = _import()
114
+ package_data = metadata("stardist")
115
+ doi_2d = "https://doi.org/10.1007/978-3-030-00934-2_30"
116
+ doi_3d = "https://doi.org/10.1109/WACV45572.2020.9093435"
117
+ authors = {
118
+ 'Martin Weigert': dict(name='Martin Weigert', github_user='maweigert'),
119
+ 'Uwe Schmidt': dict(name='Uwe Schmidt', github_user='uschmidt83'),
120
+ }
121
+ data = dict(
122
+ description=package_data["Summary"],
123
+ authors=list(authors.get(name.strip(),dict(name=name.strip())) for name in package_data["Author"].split(",")),
124
+ git_repo=package_data["Home-Page"],
125
+ license=package_data["License"],
126
+ dependencies=_create_stardist_dependencies(outdir),
127
+ cite=[{"text": "Cell Detection with Star-Convex Polygons", "doi": doi_2d},
128
+ {"text": "Star-convex Polyhedra for 3D Object Detection and Segmentation in Microscopy", "doi": doi_3d}],
129
+ tags=[
130
+ 'fluorescence-light-microscopy', 'whole-slide-imaging', 'other', # modality
131
+ f'{model.config.n_dim}d', # dims
132
+ 'cells', 'nuclei', # content
133
+ 'tensorflow', # framework
134
+ 'fiji', # software
135
+ 'unet', # network
136
+ 'instance-segmentation', 'object-detection', # task
137
+ 'stardist',
138
+ ],
139
+ covers=["https://raw.githubusercontent.com/stardist/stardist/master/images/stardist_logo.jpg"],
140
+ documentation=_create_stardist_doc(outdir),
141
+ )
142
+ return data
143
+
144
+
145
+ def _predict_tf(model_path, test_input):
146
+ import tensorflow as tf
147
+ from csbdeep.utils.tf import IS_TF_1
148
+ # need to unzip the model assets
149
+ model_assets = model_path.parent / "tf_model"
150
+ with ZipFile(model_path, "r") as f:
151
+ f.extractall(model_assets)
152
+ if IS_TF_1:
153
+ # make a new graph, i.e. don't use the global default graph
154
+ with tf.Graph().as_default():
155
+ with tf.Session() as sess:
156
+ tf_model = tf.saved_model.load_v2(str(model_assets))
157
+ x = tf.convert_to_tensor(test_input, dtype=tf.float32)
158
+ model = tf_model.signatures["serving_default"]
159
+ y = model(x)
160
+ sess.run(tf.global_variables_initializer())
161
+ output = sess.run(y["output"])
162
+ else:
163
+ tf_model = tf.saved_model.load(str(model_assets))
164
+ x = tf.convert_to_tensor(test_input, dtype=tf.float32)
165
+ model = tf_model.signatures["serving_default"]
166
+ y = model(x)
167
+ output = y["output"].numpy()
168
+ return output
169
+
170
+
171
+ def _get_weights_and_model_metadata(outdir, model, test_input, test_input_axes, test_input_norm_axes, mode, min_percentile, max_percentile):
172
+
173
+ # get the path to the exported model assets (saved in outdir)
174
+ if mode == "keras_hdf5":
175
+ raise NotImplementedError("Export to keras format is not supported yet")
176
+ elif mode == "tensorflow_saved_model_bundle":
177
+ assets_uri = outdir / "TF_SavedModel.zip"
178
+ model_csbdeep = model.export_TF(assets_uri, single_output=True, upsample_grid=True)
179
+ else:
180
+ raise ValueError(f"Unsupported mode: {mode}")
181
+
182
+ # to force "inputs.data_type: float32" in the spec (bonus: disables normalization warning in model._predict_setup)
183
+ test_input = test_input.astype(np.float32)
184
+
185
+ # convert test_input to axes_net semantics and shape, also resize if necessary (to adhere to axes_net_div_by)
186
+ test_input, axes_img, axes_net, axes_net_div_by, *_ = model._predict_setup(
187
+ img=test_input,
188
+ axes=test_input_axes,
189
+ normalizer=None,
190
+ n_tiles=None,
191
+ show_tile_progress=False,
192
+ predict_kwargs={},
193
+ )
194
+
195
+ # normalization axes string and numeric indices
196
+ axes_norm = set(axes_net).intersection(set(axes_check_and_normalize(test_input_norm_axes, disallowed='S')))
197
+ axes_norm = "".join(a for a in axes_net if a in axes_norm) # preserve order of axes_net
198
+ axes_norm_num = tuple(axes_net.index(a) for a in axes_norm)
199
+
200
+ # normalize input image
201
+ test_input_norm = normalize(test_input, pmin=min_percentile, pmax=max_percentile, axis=axes_norm_num)
202
+
203
+ net_axes_in = axes_net.lower()
204
+ net_axes_out = axes_check_and_normalize(model._axes_out).lower()
205
+ ndim_tensor = len(net_axes_out) + 1
206
+
207
+ input_min_shape = list(axes_net_div_by)
208
+ input_min_shape[axes_net.index('C')] = model.config.n_channel_in
209
+ input_step = list(axes_net_div_by)
210
+ input_step[axes_net.index('C')] = 0
211
+
212
+ # add the batch axis to shape and step
213
+ input_min_shape = [1] + input_min_shape
214
+ input_step = [0] + input_step
215
+
216
+ # the axes strings in bioimageio convention
217
+ input_axes = "b" + net_axes_in.lower()
218
+ output_axes = "b" + net_axes_out.lower()
219
+
220
+ if mode == "keras_hdf5":
221
+ output_names = ("prob", "dist") + (("class_prob",) if model._is_multiclass() else ())
222
+ output_n_channels = (1, model.config.n_rays,) + ((1,) if model._is_multiclass() else ())
223
+ # the output shape is computed from the input shape using
224
+ # output_shape[i] = output_scale[i] * input_shape[i] + 2 * output_offset[i]
225
+ output_scale = [1]+list(1/g for g in model.config.grid) + [0]
226
+ output_offset = [0]*(ndim_tensor)
227
+
228
+ elif mode == "tensorflow_saved_model_bundle":
229
+ if model._is_multiclass():
230
+ raise NotImplementedError("Tensorflow SavedModel not supported for multiclass models yet")
231
+ # regarding input/output names: https://github.com/CSBDeep/CSBDeep/blob/b0d2f5f344ebe65a9b4c3007f4567fe74268c813/csbdeep/utils/tf.py#L193-L194
232
+ input_names = ["input"]
233
+ output_names = ["output"]
234
+ output_n_channels = (1 + model.config.n_rays,)
235
+ # the output shape is computed from the input shape using
236
+ # output_shape[i] = output_scale[i] * input_shape[i] + 2 * output_offset[i]
237
+ # same shape as input except for the channel dimension
238
+ output_scale = [1]*(ndim_tensor)
239
+ output_scale[output_axes.index("c")] = 0
240
+ # no offset, except for the input axes, where it is output channel / 2
241
+ output_offset = [0.0]*(ndim_tensor)
242
+ output_offset[output_axes.index("c")] = output_n_channels[0] / 2.0
243
+
244
+ assert all(s in (0, 1) for s in output_scale), "halo computation assumption violated"
245
+ halo = model._axes_tile_overlap(output_axes.replace('b', 's'))
246
+ halo = [int(np.ceil(v/8)*8) for v in halo] # optional: round up to be divisible by 8
247
+
248
+ # the output shape needs to be valid after cropping the halo, so we add the halo to the input min shape
249
+ input_min_shape = [ms + 2 * ha for ms, ha in zip(input_min_shape, halo)]
250
+
251
+ # make sure the input min shape is still divisible by the min axis divisor
252
+ input_min_shape = input_min_shape[:1] + [ms + (-ms % div_by) for ms, div_by in zip(input_min_shape[1:], axes_net_div_by)]
253
+ assert all(ms % div_by == 0 for ms, div_by in zip(input_min_shape[1:], axes_net_div_by))
254
+
255
+ metadata, *_ = _import()
256
+ package_data = metadata("stardist")
257
+ is_2D = model.config.n_dim == 2
258
+
259
+ weights_file = outdir / "stardist_weights.h5"
260
+ model.keras_model.save_weights(str(weights_file))
261
+
262
+ config = dict(
263
+ stardist=dict(
264
+ python_version=package_data["Version"],
265
+ thresholds=dict(model.thresholds._asdict()),
266
+ weights=weights_file.name,
267
+ config=vars(model.config),
268
+ )
269
+ )
270
+
271
+ if is_2D:
272
+ macro_file = outdir / "stardist_postprocessing.ijm"
273
+ with open(str(macro_file), 'w', encoding='utf-8') as f:
274
+ f.write(DEEPIMAGEJ_MACRO.format(probThresh=model.thresholds.prob, nmsThresh=model.thresholds.nms))
275
+ config['stardist'].update(postprocessing_macro=macro_file.name)
276
+
277
+ n_inputs = len(input_names)
278
+ assert n_inputs == 1
279
+ input_config = dict(
280
+ input_names=input_names,
281
+ input_min_shape=[input_min_shape],
282
+ input_step=[input_step],
283
+ input_axes=[input_axes],
284
+ input_data_range=[["-inf", "inf"]],
285
+ preprocessing=[[dict(
286
+ name="scale_range",
287
+ kwargs=dict(
288
+ mode="per_sample",
289
+ axes=axes_norm.lower(),
290
+ min_percentile=min_percentile,
291
+ max_percentile=max_percentile,
292
+ ))]]
293
+ )
294
+
295
+ n_outputs = len(output_names)
296
+ output_config = dict(
297
+ output_names=output_names,
298
+ output_data_range=[["-inf", "inf"]] * n_outputs,
299
+ output_axes=[output_axes] * n_outputs,
300
+ output_reference=[input_names[0]] * n_outputs,
301
+ output_scale=[output_scale] * n_outputs,
302
+ output_offset=[output_offset] * n_outputs,
303
+ halo=[halo] * n_outputs
304
+ )
305
+
306
+ in_path = outdir / "test_input.npy"
307
+ np.save(in_path, test_input[np.newaxis])
308
+
309
+ if mode == "tensorflow_saved_model_bundle":
310
+ test_outputs = _predict_tf(assets_uri, test_input_norm[np.newaxis])
311
+ else:
312
+ test_outputs = model.predict(test_input_norm)
313
+
314
+ # out_paths = []
315
+ # for i, out in enumerate(test_outputs):
316
+ # p = outdir / f"test_output{i}.npy"
317
+ # np.save(p, out)
318
+ # out_paths.append(p)
319
+ assert n_outputs == 1
320
+ out_paths = [outdir / "test_output.npy"]
321
+ np.save(out_paths[0], test_outputs)
322
+
323
+ from tensorflow import __version__ as tf_version
324
+ data = dict(weight_uri=assets_uri, test_inputs=[in_path], test_outputs=out_paths,
325
+ config=config, tensorflow_version=tf_version)
326
+ data.update(input_config)
327
+ data.update(output_config)
328
+ _files = [str(weights_file)]
329
+ if is_2D:
330
+ _files.append(str(macro_file))
331
+ data.update(attachments=dict(files=_files))
332
+
333
+ return data
334
+
335
+
336
+ def export_bioimageio(
337
+ model,
338
+ outpath,
339
+ test_input,
340
+ test_input_axes=None,
341
+ test_input_norm_axes='ZYX',
342
+ name=None,
343
+ mode="tensorflow_saved_model_bundle",
344
+ min_percentile=1.0,
345
+ max_percentile=99.8,
346
+ overwrite_spec_kwargs=None,
347
+ ):
348
+ """Export stardist model into bioimage.io format, https://github.com/bioimage-io/spec-bioimage-io.
349
+
350
+ Parameters
351
+ ----------
352
+ model: StarDist2D, StarDist3D
353
+ the model to convert
354
+ outpath: str, Path
355
+ where to save the model
356
+ test_input: np.ndarray
357
+ input image for generating test data
358
+ test_input_axes: str or None
359
+ the axes of the test input, for example 'YX' for a 2d image or 'ZYX' for a 3d volume
360
+ using None assumes that axes of test_input are the same as those of model
361
+ test_input_norm_axes: str
362
+ the axes of the test input which will be jointly normalized, for example 'ZYX' for all spatial dimensions ('Z' ignored for 2D input)
363
+ use 'ZYXC' to also jointly normalize channels (e.g. for RGB input images)
364
+ name: str
365
+ the name of this model (default: None)
366
+ if None, uses the (folder) name of the model (i.e. `model.name`)
367
+ mode: str
368
+ the export type for this model (default: "tensorflow_saved_model_bundle")
369
+ min_percentile: float
370
+ min percentile to be used for image normalization (default: 1.0)
371
+ max_percentile: float
372
+ max percentile to be used for image normalization (default: 99.8)
373
+ overwrite_spec_kwargs: dict or None
374
+ spec keywords that should be overloaded (default: None)
375
+ """
376
+ _, build_model, *_ = _import()
377
+ from .models import StarDist2D, StarDist3D
378
+ isinstance(model, (StarDist2D, StarDist3D)) or _raise(ValueError("not a valid model"))
379
+ 0 <= min_percentile < max_percentile <= 100 or _raise(ValueError("invalid percentile values"))
380
+
381
+ if name is None:
382
+ name = model.name
383
+ name = str(name)
384
+
385
+ outpath = Path(outpath)
386
+ if outpath.suffix == "":
387
+ outdir = outpath
388
+ zip_path = outdir / f"{name}.zip"
389
+ elif outpath.suffix == ".zip":
390
+ outdir = outpath.parent
391
+ zip_path = outpath
392
+ else:
393
+ raise ValueError(f"outpath has to be a folder or zip file, got {outpath}")
394
+ outdir.mkdir(exist_ok=True, parents=True)
395
+
396
+ with tempfile.TemporaryDirectory() as _tmp_dir:
397
+ tmp_dir = Path(_tmp_dir)
398
+ kwargs = _get_stardist_metadata(tmp_dir, model)
399
+ model_kwargs = _get_weights_and_model_metadata(tmp_dir, model, test_input, test_input_axes, test_input_norm_axes, mode,
400
+ min_percentile=min_percentile, max_percentile=max_percentile)
401
+ kwargs.update(model_kwargs)
402
+ if overwrite_spec_kwargs is not None:
403
+ kwargs.update(overwrite_spec_kwargs)
404
+
405
+ build_model(name=name, output_path=zip_path, add_deepimagej_config=(model.config.n_dim==2), root=tmp_dir, **kwargs)
406
+ print(f"\nbioimage.io model with name '{name}' exported to '{zip_path}'")
407
+
408
+
409
+ def import_bioimageio(source, outpath):
410
+ """Import stardist model from bioimage.io format, https://github.com/bioimage-io/spec-bioimage-io.
411
+
412
+ Load a model in bioimage.io format from the given `source` (e.g. path to zip file, URL)
413
+ and convert it to a regular stardist model, which will be saved in the folder `outpath`.
414
+
415
+ Parameters
416
+ ----------
417
+ source: str, Path
418
+ bioimage.io resource (e.g. path, URL)
419
+ outpath: str, Path
420
+ folder to save the stardist model (must not exist previously)
421
+
422
+ Returns
423
+ -------
424
+ StarDist2D or StarDist3D
425
+ stardist model loaded from `outpath`
426
+
427
+ """
428
+ import shutil, uuid
429
+ from csbdeep.utils import save_json
430
+ from .models import StarDist2D, StarDist3D
431
+ *_, bioimageio_core, _ = _import()
432
+
433
+ outpath = Path(outpath)
434
+ not outpath.exists() or _raise(FileExistsError(f"'{outpath}' already exists"))
435
+
436
+ with tempfile.TemporaryDirectory() as _tmp_dir:
437
+ tmp_dir = Path(_tmp_dir)
438
+ # download the full model content to a temporary folder
439
+ zip_path = tmp_dir / f"{str(uuid.uuid4())}.zip"
440
+ bioimageio_core.export_resource_package(source, output_path=zip_path)
441
+ with ZipFile(zip_path, "r") as zip_ref:
442
+ zip_ref.extractall(tmp_dir)
443
+ zip_path.unlink()
444
+ rdf_path = tmp_dir / "rdf.yaml"
445
+ biomodel = bioimageio_core.load_resource_description(rdf_path)
446
+
447
+ # read the stardist specific content
448
+ 'stardist' in biomodel.config or _raise(RuntimeError("bioimage.io model not compatible"))
449
+ config = biomodel.config['stardist']['config']
450
+ thresholds = biomodel.config['stardist']['thresholds']
451
+ weights = biomodel.config['stardist']['weights']
452
+
453
+ # make sure that the keras weights are in the attachments
454
+ weights_file = None
455
+ for f in biomodel.attachments.files:
456
+ if f.name == weights and f.exists():
457
+ weights_file = f
458
+ break
459
+ weights_file is not None or _raise(FileNotFoundError(f"couldn't find weights file '{weights}'"))
460
+
461
+ # save the config and threshold to json, and weights to hdf5 to enable loading as stardist model
462
+ # copy bioimageio files to separate sub-folder
463
+ outpath.mkdir(parents=True)
464
+ save_json(config, str(outpath / 'config.json'))
465
+ save_json(thresholds, str(outpath / 'thresholds.json'))
466
+ shutil.copy(str(weights_file), str(outpath / "weights_bioimageio.h5"))
467
+ shutil.copytree(str(tmp_dir), str(outpath / "bioimageio"))
468
+
469
+ model_class = (StarDist2D if config['n_dim'] == 2 else StarDist3D)
470
+ model = model_class(None, outpath.name, basedir=str(outpath.parent))
471
+
472
+ return model
stardist_pkg/geometry/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import absolute_import, print_function
2
+
3
+ # TODO: rethink naming for 2D/3D functions
4
+
5
+ from .geom2d import star_dist, relabel_image_stardist, ray_angles, dist_to_coord, polygons_to_label, polygons_to_label_coord
6
+
7
+ from .geom2d import _dist_to_coord_old, _polygons_to_label_old
8
+
9
+ #, dist_to_volume, dist_to_centroid
stardist_pkg/geometry/__pycache__/__init__.cpython-37.pyc ADDED
Binary file (522 Bytes). View file
 
stardist_pkg/geometry/__pycache__/geom2d.cpython-37.pyc ADDED
Binary file (7.23 kB). View file
 
stardist_pkg/geometry/__pycache__/geom3d.cpython-37.pyc ADDED
Binary file (11 kB). View file
 
stardist_pkg/geometry/geom2d.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import print_function, unicode_literals, absolute_import, division
2
+ import numpy as np
3
+ import warnings
4
+
5
+ from skimage.measure import regionprops
6
+ from skimage.draw import polygon
7
+ from csbdeep.utils import _raise
8
+
9
+ from ..utils import path_absolute, _is_power_of_2, _normalize_grid
10
+ from ..matching import _check_label_array
11
+ from stardist.lib.stardist2d import c_star_dist
12
+
13
+
14
+
15
+ def _ocl_star_dist(lbl, n_rays=32, grid=(1,1)):
16
+ from gputools import OCLProgram, OCLArray, OCLImage
17
+ (np.isscalar(n_rays) and 0 < int(n_rays)) or _raise(ValueError())
18
+ n_rays = int(n_rays)
19
+ # slicing with grid is done with tuple(slice(0, None, g) for g in grid)
20
+ res_shape = tuple((s-1)//g+1 for s, g in zip(lbl.shape, grid))
21
+
22
+ src = OCLImage.from_array(lbl.astype(np.uint16,copy=False))
23
+ dst = OCLArray.empty(res_shape+(n_rays,), dtype=np.float32)
24
+ program = OCLProgram(path_absolute("kernels/stardist2d.cl"), build_options=['-D', 'N_RAYS=%d' % n_rays])
25
+ program.run_kernel('star_dist', res_shape[::-1], None, dst.data, src, np.int32(grid[0]),np.int32(grid[1]))
26
+ return dst.get()
27
+
28
+
29
+ def _cpp_star_dist(lbl, n_rays=32, grid=(1,1)):
30
+ (np.isscalar(n_rays) and 0 < int(n_rays)) or _raise(ValueError())
31
+ return c_star_dist(lbl.astype(np.uint16,copy=False), np.int32(n_rays), np.int32(grid[0]),np.int32(grid[1]))
32
+
33
+
34
+ def _py_star_dist(a, n_rays=32, grid=(1,1)):
35
+ (np.isscalar(n_rays) and 0 < int(n_rays)) or _raise(ValueError())
36
+ if grid != (1,1):
37
+ raise NotImplementedError(grid)
38
+
39
+ n_rays = int(n_rays)
40
+ a = a.astype(np.uint16,copy=False)
41
+ dst = np.empty(a.shape+(n_rays,),np.float32)
42
+
43
+ for i in range(a.shape[0]):
44
+ for j in range(a.shape[1]):
45
+ value = a[i,j]
46
+ if value == 0:
47
+ dst[i,j] = 0
48
+ else:
49
+ st_rays = np.float32((2*np.pi) / n_rays)
50
+ for k in range(n_rays):
51
+ phi = np.float32(k*st_rays)
52
+ dy = np.cos(phi)
53
+ dx = np.sin(phi)
54
+ x, y = np.float32(0), np.float32(0)
55
+ while True:
56
+ x += dx
57
+ y += dy
58
+ ii = int(round(i+x))
59
+ jj = int(round(j+y))
60
+ if (ii < 0 or ii >= a.shape[0] or
61
+ jj < 0 or jj >= a.shape[1] or
62
+ value != a[ii,jj]):
63
+ # small correction as we overshoot the boundary
64
+ t_corr = 1-.5/max(np.abs(dx),np.abs(dy))
65
+ x -= t_corr*dx
66
+ y -= t_corr*dy
67
+ dist = np.sqrt(x**2+y**2)
68
+ dst[i,j,k] = dist
69
+ break
70
+ return dst
71
+
72
+
73
+ def star_dist(a, n_rays=32, grid=(1,1), mode='cpp'):
74
+ """'a' assumbed to be a label image with integer values that encode object ids. id 0 denotes background."""
75
+
76
+ n_rays >= 3 or _raise(ValueError("need 'n_rays' >= 3"))
77
+
78
+ if mode == 'python':
79
+ return _py_star_dist(a, n_rays, grid=grid)
80
+ elif mode == 'cpp':
81
+ return _cpp_star_dist(a, n_rays, grid=grid)
82
+ elif mode == 'opencl':
83
+ return _ocl_star_dist(a, n_rays, grid=grid)
84
+ else:
85
+ _raise(ValueError("Unknown mode %s" % mode))
86
+
87
+
88
+ def _dist_to_coord_old(rhos, grid=(1,1)):
89
+ """convert from polar to cartesian coordinates for a single image (3-D array) or multiple images (4-D array)"""
90
+
91
+ grid = _normalize_grid(grid,2)
92
+ is_single_image = rhos.ndim == 3
93
+ if is_single_image:
94
+ rhos = np.expand_dims(rhos,0)
95
+ assert rhos.ndim == 4
96
+
97
+ n_images,h,w,n_rays = rhos.shape
98
+ coord = np.empty((n_images,h,w,2,n_rays),dtype=rhos.dtype)
99
+
100
+ start = np.indices((h,w))
101
+ for i in range(2):
102
+ coord[...,i,:] = grid[i] * np.broadcast_to(start[i].reshape(1,h,w,1), (n_images,h,w,n_rays))
103
+
104
+ phis = ray_angles(n_rays).reshape(1,1,1,n_rays)
105
+
106
+ coord[...,0,:] += rhos * np.sin(phis) # row coordinate
107
+ coord[...,1,:] += rhos * np.cos(phis) # col coordinate
108
+
109
+ return coord[0] if is_single_image else coord
110
+
111
+
112
+ def _polygons_to_label_old(coord, prob, points, shape=None, thr=-np.inf):
113
+ sh = coord.shape[:2] if shape is None else shape
114
+ lbl = np.zeros(sh,np.int32)
115
+ # sort points with increasing probability
116
+ ind = np.argsort([ prob[p[0],p[1]] for p in points ])
117
+ points = points[ind]
118
+
119
+ i = 1
120
+ for p in points:
121
+ if prob[p[0],p[1]] < thr:
122
+ continue
123
+ rr,cc = polygon(coord[p[0],p[1],0], coord[p[0],p[1],1], sh)
124
+ lbl[rr,cc] = i
125
+ i += 1
126
+
127
+ return lbl
128
+
129
+
130
+ def dist_to_coord(dist, points, scale_dist=(1,1)):
131
+ """convert from polar to cartesian coordinates for a list of distances and center points
132
+ dist.shape = (n_polys, n_rays)
133
+ points.shape = (n_polys, 2)
134
+ len(scale_dist) = 2
135
+ return coord.shape = (n_polys,2,n_rays)
136
+ """
137
+ dist = np.asarray(dist)
138
+ points = np.asarray(points)
139
+ assert dist.ndim==2 and points.ndim==2 and len(dist)==len(points) \
140
+ and points.shape[1]==2 and len(scale_dist)==2
141
+ n_rays = dist.shape[1]
142
+ phis = ray_angles(n_rays)
143
+ coord = (dist[:,np.newaxis]*np.array([np.sin(phis),np.cos(phis)])).astype(np.float32)
144
+ coord *= np.asarray(scale_dist).reshape(1,2,1)
145
+ coord += points[...,np.newaxis]
146
+ return coord
147
+
148
+
149
+ def polygons_to_label_coord(coord, shape, labels=None):
150
+ """renders polygons to image of given shape
151
+
152
+ coord.shape = (n_polys, n_rays)
153
+ """
154
+ coord = np.asarray(coord)
155
+ if labels is None: labels = np.arange(len(coord))
156
+
157
+ _check_label_array(labels, "labels")
158
+ assert coord.ndim==3 and coord.shape[1]==2 and len(coord)==len(labels)
159
+
160
+ lbl = np.zeros(shape,np.int32)
161
+
162
+ for i,c in zip(labels,coord):
163
+ rr,cc = polygon(*c, shape)
164
+ lbl[rr,cc] = i+1
165
+
166
+ return lbl
167
+
168
+
169
+ def polygons_to_label(dist, points, shape, prob=None, thr=-np.inf, scale_dist=(1,1)):
170
+ """converts distances and center points to label image
171
+
172
+ dist.shape = (n_polys, n_rays)
173
+ points.shape = (n_polys, 2)
174
+
175
+ label ids will be consecutive and adhere to the order given
176
+ """
177
+ dist = np.asarray(dist)
178
+ points = np.asarray(points)
179
+ prob = np.inf*np.ones(len(points)) if prob is None else np.asarray(prob)
180
+
181
+ assert dist.ndim==2 and points.ndim==2 and len(dist)==len(points)
182
+ assert len(points)==len(prob) and points.shape[1]==2 and prob.ndim==1
183
+
184
+ n_rays = dist.shape[1]
185
+
186
+ ind = prob>thr
187
+ points = points[ind]
188
+ dist = dist[ind]
189
+ prob = prob[ind]
190
+
191
+ ind = np.argsort(prob, kind='stable')
192
+ points = points[ind]
193
+ dist = dist[ind]
194
+
195
+ coord = dist_to_coord(dist, points, scale_dist=scale_dist)
196
+
197
+ return polygons_to_label_coord(coord, shape=shape, labels=ind)
198
+
199
+
200
+ def relabel_image_stardist(lbl, n_rays, **kwargs):
201
+ """relabel each label region in `lbl` with its star representation"""
202
+ _check_label_array(lbl, "lbl")
203
+ if not lbl.ndim==2:
204
+ raise ValueError("lbl image should be 2 dimensional")
205
+ dist = star_dist(lbl, n_rays, **kwargs)
206
+ points = np.array(tuple(np.array(r.centroid).astype(int) for r in regionprops(lbl)))
207
+ dist = dist[tuple(points.T)]
208
+ return polygons_to_label(dist, points, shape=lbl.shape)
209
+
210
+
211
+ def ray_angles(n_rays=32):
212
+ return np.linspace(0,2*np.pi,n_rays,endpoint=False)
stardist_pkg/kernels/stardist2d.cl ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #ifndef M_PI
2
+ #define M_PI 3.141592653589793
3
+ #endif
4
+
5
+ __constant sampler_t sampler = CLK_NORMALIZED_COORDS_FALSE | CLK_ADDRESS_CLAMP | CLK_FILTER_NEAREST;
6
+
7
+ inline float2 pol2cart(const float rho, const float phi) {
8
+ const float x = rho * cos(phi);
9
+ const float y = rho * sin(phi);
10
+ return (float2)(x,y);
11
+ }
12
+
13
+ __kernel void star_dist(__global float* dst, read_only image2d_t src, const int grid_y, const int grid_x) {
14
+
15
+ const int i = get_global_id(0), j = get_global_id(1);
16
+ const int Nx = get_global_size(0), Ny = get_global_size(1);
17
+ const float2 grid = (float2)(grid_x, grid_y);
18
+
19
+ const float2 origin = (float2)(i,j) * grid;
20
+ const int value = read_imageui(src,sampler,origin).x;
21
+
22
+ if (value == 0) {
23
+ // background pixel -> nothing to do, write all zeros
24
+ for (int k = 0; k < N_RAYS; k++) {
25
+ dst[k + i*N_RAYS + j*N_RAYS*Nx] = 0;
26
+ }
27
+ } else {
28
+ float st_rays = (2*M_PI) / N_RAYS; // step size for ray angles
29
+ // for all rays
30
+ for (int k = 0; k < N_RAYS; k++) {
31
+ const float phi = k*st_rays; // current ray angle phi
32
+ const float2 dir = pol2cart(1,phi); // small vector in direction of ray
33
+ float2 offset = 0; // offset vector to be added to origin
34
+ // find radius that leaves current object
35
+ while (1) {
36
+ offset += dir;
37
+ const int offset_value = read_imageui(src,sampler,round(origin+offset)).x;
38
+ if (offset_value != value) {
39
+ // small correction as we overshoot the boundary
40
+ const float t_corr = .5f/fmax(fabs(dir.x),fabs(dir.y));
41
+ offset += (t_corr-1.f)*dir;
42
+
43
+ const float dist = sqrt(offset.x*offset.x + offset.y*offset.y);
44
+ dst[k + i*N_RAYS + j*N_RAYS*Nx] = dist;
45
+ break;
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ }
stardist_pkg/kernels/stardist3d.cl ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #ifndef M_PI
2
+ #define M_PI 3.141592653589793
3
+ #endif
4
+
5
+ __constant sampler_t sampler = CLK_NORMALIZED_COORDS_FALSE | CLK_ADDRESS_CLAMP | CLK_FILTER_NEAREST;
6
+
7
+ inline int round_to_int(float r) {
8
+ return (int)rint(r);
9
+ }
10
+
11
+
12
+ __kernel void stardist3d(read_only image3d_t lbl, __constant float * rays, __global float* dist, const int grid_z, const int grid_y, const int grid_x) {
13
+
14
+ const int i = get_global_id(0);
15
+ const int j = get_global_id(1);
16
+ const int k = get_global_id(2);
17
+
18
+ const int Nx = get_global_size(0);
19
+ const int Ny = get_global_size(1);
20
+ const int Nz = get_global_size(2);
21
+
22
+ const float4 grid = (float4)(grid_x, grid_y, grid_z, 1);
23
+ const float4 origin = (float4)(i,j,k,0) * grid;
24
+ const int value = read_imageui(lbl,sampler,origin).x;
25
+
26
+ if (value == 0) {
27
+ // background pixel -> nothing to do, write all zeros
28
+ for (int m = 0; m < N_RAYS; m++) {
29
+ dist[m + i*N_RAYS + j*N_RAYS*Nx+k*N_RAYS*Nx*Ny] = 0;
30
+ }
31
+
32
+ }
33
+ else {
34
+ for (int m = 0; m < N_RAYS; m++) {
35
+
36
+ const float4 dx = (float4)(rays[3*m+2],rays[3*m+1],rays[3*m],0);
37
+ // if ((i==Nx/2)&&(j==Ny/2)&(k==Nz/2)){
38
+ // printf("kernel: %.2f %.2f %.2f \n",dx.x,dx.y,dx.z);
39
+ // }
40
+ float4 x = (float4)(0,0,0,0);
41
+
42
+ // move along ray
43
+ while (1) {
44
+ x += dx;
45
+ // if ((i==10)&&(j==10)&(k==10)){
46
+ // printf("kernel run: %.2f %.2f %.2f value %d \n",x.x,x.y,x.z, read_imageui(lbl,sampler,origin+x).x);
47
+ // }
48
+
49
+ // to make it equivalent to the cpp version...
50
+ const float4 x_int = (float4)(round_to_int(x.x),
51
+ round_to_int(x.y),
52
+ round_to_int(x.z),
53
+ 0);
54
+
55
+ if (value != read_imageui(lbl,sampler,origin+x_int).x){
56
+
57
+ dist[m + i*N_RAYS + j*N_RAYS*Nx+k*N_RAYS*Nx*Ny] = length(x_int);
58
+ break;
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
stardist_pkg/matching.py ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+ from numba import jit
4
+ from tqdm import tqdm
5
+ from scipy.optimize import linear_sum_assignment
6
+ from skimage.measure import regionprops
7
+ from collections import namedtuple
8
+ from csbdeep.utils import _raise
9
+
10
+ matching_criteria = dict()
11
+
12
+
13
+ def label_are_sequential(y):
14
+ """ returns true if y has only sequential labels from 1... """
15
+ labels = np.unique(y)
16
+ return (set(labels)-{0}) == set(range(1,1+labels.max()))
17
+
18
+
19
+ def is_array_of_integers(y):
20
+ return isinstance(y,np.ndarray) and np.issubdtype(y.dtype, np.integer)
21
+
22
+
23
+ def _check_label_array(y, name=None, check_sequential=False):
24
+ err = ValueError("{label} must be an array of {integers}.".format(
25
+ label = 'labels' if name is None else name,
26
+ integers = ('sequential ' if check_sequential else '') + 'non-negative integers',
27
+ ))
28
+ is_array_of_integers(y) or _raise(err)
29
+ if len(y) == 0:
30
+ return True
31
+ if check_sequential:
32
+ label_are_sequential(y) or _raise(err)
33
+ else:
34
+ y.min() >= 0 or _raise(err)
35
+ return True
36
+
37
+
38
+ def label_overlap(x, y, check=True):
39
+ if check:
40
+ _check_label_array(x,'x',True)
41
+ _check_label_array(y,'y',True)
42
+ x.shape == y.shape or _raise(ValueError("x and y must have the same shape"))
43
+ return _label_overlap(x, y)
44
+
45
+ @jit(nopython=True)
46
+ def _label_overlap(x, y):
47
+ x = x.ravel()
48
+ y = y.ravel()
49
+ overlap = np.zeros((1+x.max(),1+y.max()), dtype=np.uint)
50
+ for i in range(len(x)):
51
+ overlap[x[i],y[i]] += 1
52
+ return overlap
53
+
54
+
55
+ def _safe_divide(x,y, eps=1e-10):
56
+ """computes a safe divide which returns 0 if y is zero"""
57
+ if np.isscalar(x) and np.isscalar(y):
58
+ return x/y if np.abs(y)>eps else 0.0
59
+ else:
60
+ out = np.zeros(np.broadcast(x,y).shape, np.float32)
61
+ np.divide(x,y, out=out, where=np.abs(y)>eps)
62
+ return out
63
+
64
+
65
+ def intersection_over_union(overlap):
66
+ _check_label_array(overlap,'overlap')
67
+ if np.sum(overlap) == 0:
68
+ return overlap
69
+ n_pixels_pred = np.sum(overlap, axis=0, keepdims=True)
70
+ n_pixels_true = np.sum(overlap, axis=1, keepdims=True)
71
+ return _safe_divide(overlap, (n_pixels_pred + n_pixels_true - overlap))
72
+
73
+ matching_criteria['iou'] = intersection_over_union
74
+
75
+
76
+ def intersection_over_true(overlap):
77
+ _check_label_array(overlap,'overlap')
78
+ if np.sum(overlap) == 0:
79
+ return overlap
80
+ n_pixels_true = np.sum(overlap, axis=1, keepdims=True)
81
+ return _safe_divide(overlap, n_pixels_true)
82
+
83
+ matching_criteria['iot'] = intersection_over_true
84
+
85
+
86
+ def intersection_over_pred(overlap):
87
+ _check_label_array(overlap,'overlap')
88
+ if np.sum(overlap) == 0:
89
+ return overlap
90
+ n_pixels_pred = np.sum(overlap, axis=0, keepdims=True)
91
+ return _safe_divide(overlap, n_pixels_pred)
92
+
93
+ matching_criteria['iop'] = intersection_over_pred
94
+
95
+
96
+ def precision(tp,fp,fn):
97
+ return tp/(tp+fp) if tp > 0 else 0
98
+ def recall(tp,fp,fn):
99
+ return tp/(tp+fn) if tp > 0 else 0
100
+ def accuracy(tp,fp,fn):
101
+ # also known as "average precision" (?)
102
+ # -> https://www.kaggle.com/c/data-science-bowl-2018#evaluation
103
+ return tp/(tp+fp+fn) if tp > 0 else 0
104
+ def f1(tp,fp,fn):
105
+ # also known as "dice coefficient"
106
+ return (2*tp)/(2*tp+fp+fn) if tp > 0 else 0
107
+
108
+
109
+ def matching(y_true, y_pred, thresh=0.5, criterion='iou', report_matches=False):
110
+ """Calculate detection/instance segmentation metrics between ground truth and predicted label images.
111
+
112
+ Currently, the following metrics are implemented:
113
+
114
+ 'fp', 'tp', 'fn', 'precision', 'recall', 'accuracy', 'f1', 'criterion', 'thresh', 'n_true', 'n_pred', 'mean_true_score', 'mean_matched_score', 'panoptic_quality'
115
+
116
+ Corresponding objects of y_true and y_pred are counted as true positives (tp), false positives (fp), and false negatives (fn)
117
+ whether their intersection over union (IoU) >= thresh (for criterion='iou', which can be changed)
118
+
119
+ * mean_matched_score is the mean IoUs of matched true positives
120
+
121
+ * mean_true_score is the mean IoUs of matched true positives but normalized by the total number of GT objects
122
+
123
+ * panoptic_quality defined as in Eq. 1 of Kirillov et al. "Panoptic Segmentation", CVPR 2019
124
+
125
+ Parameters
126
+ ----------
127
+ y_true: ndarray
128
+ ground truth label image (integer valued)
129
+ y_pred: ndarray
130
+ predicted label image (integer valued)
131
+ thresh: float
132
+ threshold for matching criterion (default 0.5)
133
+ criterion: string
134
+ matching criterion (default IoU)
135
+ report_matches: bool
136
+ if True, additionally calculate matched_pairs and matched_scores (note, that this returns even gt-pred pairs whose scores are below 'thresh')
137
+
138
+ Returns
139
+ -------
140
+ Matching object with different metrics as attributes
141
+
142
+ Examples
143
+ --------
144
+ >>> y_true = np.zeros((100,100), np.uint16)
145
+ >>> y_true[10:20,10:20] = 1
146
+ >>> y_pred = np.roll(y_true,5,axis = 0)
147
+
148
+ >>> stats = matching(y_true, y_pred)
149
+ >>> print(stats)
150
+ Matching(criterion='iou', thresh=0.5, fp=1, tp=0, fn=1, precision=0, recall=0, accuracy=0, f1=0, n_true=1, n_pred=1, mean_true_score=0.0, mean_matched_score=0.0, panoptic_quality=0.0)
151
+
152
+ """
153
+ _check_label_array(y_true,'y_true')
154
+ _check_label_array(y_pred,'y_pred')
155
+ y_true.shape == y_pred.shape or _raise(ValueError("y_true ({y_true.shape}) and y_pred ({y_pred.shape}) have different shapes".format(y_true=y_true, y_pred=y_pred)))
156
+ criterion in matching_criteria or _raise(ValueError("Matching criterion '%s' not supported." % criterion))
157
+ if thresh is None: thresh = 0
158
+ thresh = float(thresh) if np.isscalar(thresh) else map(float,thresh)
159
+
160
+ y_true, _, map_rev_true = relabel_sequential(y_true)
161
+ y_pred, _, map_rev_pred = relabel_sequential(y_pred)
162
+
163
+ overlap = label_overlap(y_true, y_pred, check=False)
164
+ scores = matching_criteria[criterion](overlap)
165
+ assert 0 <= np.min(scores) <= np.max(scores) <= 1
166
+
167
+ # ignoring background
168
+ scores = scores[1:,1:]
169
+ n_true, n_pred = scores.shape
170
+ n_matched = min(n_true, n_pred)
171
+
172
+ def _single(thr):
173
+ # not_trivial = n_matched > 0 and np.any(scores >= thr)
174
+ not_trivial = n_matched > 0
175
+ if not_trivial:
176
+ # compute optimal matching with scores as tie-breaker
177
+ costs = -(scores >= thr).astype(float) - scores / (2*n_matched)
178
+ true_ind, pred_ind = linear_sum_assignment(costs)
179
+ assert n_matched == len(true_ind) == len(pred_ind)
180
+ match_ok = scores[true_ind,pred_ind] >= thr
181
+ tp = np.count_nonzero(match_ok)
182
+ else:
183
+ tp = 0
184
+ fp = n_pred - tp
185
+ fn = n_true - tp
186
+ # assert tp+fp == n_pred
187
+ # assert tp+fn == n_true
188
+
189
+ # the score sum over all matched objects (tp)
190
+ sum_matched_score = np.sum(scores[true_ind,pred_ind][match_ok]) if not_trivial else 0.0
191
+
192
+ # the score average over all matched objects (tp)
193
+ mean_matched_score = _safe_divide(sum_matched_score, tp)
194
+ # the score average over all gt/true objects
195
+ mean_true_score = _safe_divide(sum_matched_score, n_true)
196
+ panoptic_quality = _safe_divide(sum_matched_score, tp+fp/2+fn/2)
197
+
198
+ stats_dict = dict (
199
+ criterion = criterion,
200
+ thresh = thr,
201
+ fp = fp,
202
+ tp = tp,
203
+ fn = fn,
204
+ precision = precision(tp,fp,fn),
205
+ recall = recall(tp,fp,fn),
206
+ accuracy = accuracy(tp,fp,fn),
207
+ f1 = f1(tp,fp,fn),
208
+ n_true = n_true,
209
+ n_pred = n_pred,
210
+ mean_true_score = mean_true_score,
211
+ mean_matched_score = mean_matched_score,
212
+ panoptic_quality = panoptic_quality,
213
+ )
214
+ if bool(report_matches):
215
+ if not_trivial:
216
+ stats_dict.update (
217
+ # int() to be json serializable
218
+ matched_pairs = tuple((int(map_rev_true[i]),int(map_rev_pred[j])) for i,j in zip(1+true_ind,1+pred_ind)),
219
+ matched_scores = tuple(scores[true_ind,pred_ind]),
220
+ matched_tps = tuple(map(int,np.flatnonzero(match_ok))),
221
+ )
222
+ else:
223
+ stats_dict.update (
224
+ matched_pairs = (),
225
+ matched_scores = (),
226
+ matched_tps = (),
227
+ )
228
+ return namedtuple('Matching',stats_dict.keys())(*stats_dict.values())
229
+
230
+ return _single(thresh) if np.isscalar(thresh) else tuple(map(_single,thresh))
231
+
232
+
233
+
234
+ def matching_dataset(y_true, y_pred, thresh=0.5, criterion='iou', by_image=False, show_progress=True, parallel=False):
235
+ """matching metrics for list of images, see `stardist.matching.matching`
236
+ """
237
+ len(y_true) == len(y_pred) or _raise(ValueError("y_true and y_pred must have the same length."))
238
+ return matching_dataset_lazy (
239
+ tuple(zip(y_true,y_pred)), thresh=thresh, criterion=criterion, by_image=by_image, show_progress=show_progress, parallel=parallel,
240
+ )
241
+
242
+
243
+
244
+ def matching_dataset_lazy(y_gen, thresh=0.5, criterion='iou', by_image=False, show_progress=True, parallel=False):
245
+
246
+ expected_keys = set(('fp', 'tp', 'fn', 'precision', 'recall', 'accuracy', 'f1', 'criterion', 'thresh', 'n_true', 'n_pred', 'mean_true_score', 'mean_matched_score', 'panoptic_quality'))
247
+
248
+ single_thresh = False
249
+ if np.isscalar(thresh):
250
+ single_thresh = True
251
+ thresh = (thresh,)
252
+
253
+ tqdm_kwargs = {}
254
+ tqdm_kwargs['disable'] = not bool(show_progress)
255
+ if int(show_progress) > 1:
256
+ tqdm_kwargs['total'] = int(show_progress)
257
+
258
+ # compute matching stats for every pair of label images
259
+ if parallel:
260
+ from concurrent.futures import ThreadPoolExecutor
261
+ fn = lambda pair: matching(*pair, thresh=thresh, criterion=criterion, report_matches=False)
262
+ with ThreadPoolExecutor() as pool:
263
+ stats_all = tuple(pool.map(fn, tqdm(y_gen,**tqdm_kwargs)))
264
+ else:
265
+ stats_all = tuple (
266
+ matching(y_t, y_p, thresh=thresh, criterion=criterion, report_matches=False)
267
+ for y_t,y_p in tqdm(y_gen,**tqdm_kwargs)
268
+ )
269
+
270
+ # accumulate results over all images for each threshold separately
271
+ n_images, n_threshs = len(stats_all), len(thresh)
272
+ accumulate = [{} for _ in range(n_threshs)]
273
+ for stats in stats_all:
274
+ for i,s in enumerate(stats):
275
+ acc = accumulate[i]
276
+ for k,v in s._asdict().items():
277
+ if k == 'mean_true_score' and not bool(by_image):
278
+ # convert mean_true_score to "sum_matched_score"
279
+ acc[k] = acc.setdefault(k,0) + v * s.n_true
280
+ else:
281
+ try:
282
+ acc[k] = acc.setdefault(k,0) + v
283
+ except TypeError:
284
+ pass
285
+
286
+ # normalize/compute 'precision', 'recall', 'accuracy', 'f1'
287
+ for thr,acc in zip(thresh,accumulate):
288
+ set(acc.keys()) == expected_keys or _raise(ValueError("unexpected keys"))
289
+ acc['criterion'] = criterion
290
+ acc['thresh'] = thr
291
+ acc['by_image'] = bool(by_image)
292
+ if bool(by_image):
293
+ for k in ('precision', 'recall', 'accuracy', 'f1', 'mean_true_score', 'mean_matched_score', 'panoptic_quality'):
294
+ acc[k] /= n_images
295
+ else:
296
+ tp, fp, fn, n_true = acc['tp'], acc['fp'], acc['fn'], acc['n_true']
297
+ sum_matched_score = acc['mean_true_score']
298
+
299
+ mean_matched_score = _safe_divide(sum_matched_score, tp)
300
+ mean_true_score = _safe_divide(sum_matched_score, n_true)
301
+ panoptic_quality = _safe_divide(sum_matched_score, tp+fp/2+fn/2)
302
+
303
+ acc.update(
304
+ precision = precision(tp,fp,fn),
305
+ recall = recall(tp,fp,fn),
306
+ accuracy = accuracy(tp,fp,fn),
307
+ f1 = f1(tp,fp,fn),
308
+ mean_true_score = mean_true_score,
309
+ mean_matched_score = mean_matched_score,
310
+ panoptic_quality = panoptic_quality,
311
+ )
312
+
313
+ accumulate = tuple(namedtuple('DatasetMatching',acc.keys())(*acc.values()) for acc in accumulate)
314
+ return accumulate[0] if single_thresh else accumulate
315
+
316
+
317
+
318
+ # copied from scikit-image master for now (remove when part of a release)
319
+ def relabel_sequential(label_field, offset=1):
320
+ """Relabel arbitrary labels to {`offset`, ... `offset` + number_of_labels}.
321
+
322
+ This function also returns the forward map (mapping the original labels to
323
+ the reduced labels) and the inverse map (mapping the reduced labels back
324
+ to the original ones).
325
+
326
+ Parameters
327
+ ----------
328
+ label_field : numpy array of int, arbitrary shape
329
+ An array of labels, which must be non-negative integers.
330
+ offset : int, optional
331
+ The return labels will start at `offset`, which should be
332
+ strictly positive.
333
+
334
+ Returns
335
+ -------
336
+ relabeled : numpy array of int, same shape as `label_field`
337
+ The input label field with labels mapped to
338
+ {offset, ..., number_of_labels + offset - 1}.
339
+ The data type will be the same as `label_field`, except when
340
+ offset + number_of_labels causes overflow of the current data type.
341
+ forward_map : numpy array of int, shape ``(label_field.max() + 1,)``
342
+ The map from the original label space to the returned label
343
+ space. Can be used to re-apply the same mapping. See examples
344
+ for usage. The data type will be the same as `relabeled`.
345
+ inverse_map : 1D numpy array of int, of length offset + number of labels
346
+ The map from the new label space to the original space. This
347
+ can be used to reconstruct the original label field from the
348
+ relabeled one. The data type will be the same as `relabeled`.
349
+
350
+ Notes
351
+ -----
352
+ The label 0 is assumed to denote the background and is never remapped.
353
+
354
+ The forward map can be extremely big for some inputs, since its
355
+ length is given by the maximum of the label field. However, in most
356
+ situations, ``label_field.max()`` is much smaller than
357
+ ``label_field.size``, and in these cases the forward map is
358
+ guaranteed to be smaller than either the input or output images.
359
+
360
+ Examples
361
+ --------
362
+ >>> from skimage.segmentation import relabel_sequential
363
+ >>> label_field = np.array([1, 1, 5, 5, 8, 99, 42])
364
+ >>> relab, fw, inv = relabel_sequential(label_field)
365
+ >>> relab
366
+ array([1, 1, 2, 2, 3, 5, 4])
367
+ >>> fw
368
+ array([0, 1, 0, 0, 0, 2, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
369
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0,
370
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
371
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
372
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5])
373
+ >>> inv
374
+ array([ 0, 1, 5, 8, 42, 99])
375
+ >>> (fw[label_field] == relab).all()
376
+ True
377
+ >>> (inv[relab] == label_field).all()
378
+ True
379
+ >>> relab, fw, inv = relabel_sequential(label_field, offset=5)
380
+ >>> relab
381
+ array([5, 5, 6, 6, 7, 9, 8])
382
+ """
383
+ offset = int(offset)
384
+ if offset <= 0:
385
+ raise ValueError("Offset must be strictly positive.")
386
+ if np.min(label_field) < 0:
387
+ raise ValueError("Cannot relabel array that contains negative values.")
388
+ max_label = int(label_field.max()) # Ensure max_label is an integer
389
+ if not np.issubdtype(label_field.dtype, np.integer):
390
+ new_type = np.min_scalar_type(max_label)
391
+ label_field = label_field.astype(new_type)
392
+ labels = np.unique(label_field)
393
+ labels0 = labels[labels != 0]
394
+ new_max_label = offset - 1 + len(labels0)
395
+ new_labels0 = np.arange(offset, new_max_label + 1)
396
+ output_type = label_field.dtype
397
+ required_type = np.min_scalar_type(new_max_label)
398
+ if np.dtype(required_type).itemsize > np.dtype(label_field.dtype).itemsize:
399
+ output_type = required_type
400
+ forward_map = np.zeros(max_label + 1, dtype=output_type)
401
+ forward_map[labels0] = new_labels0
402
+ inverse_map = np.zeros(new_max_label + 1, dtype=output_type)
403
+ inverse_map[offset:] = labels0
404
+ relabeled = forward_map[label_field]
405
+ return relabeled, forward_map, inverse_map
406
+
407
+
408
+
409
+ def group_matching_labels(ys, thresh=1e-10, criterion='iou'):
410
+ """
411
+ Group matching objects (i.e. assign the same label id) in a
412
+ list of label images (e.g. consecutive frames of a time-lapse).
413
+
414
+ Uses function `matching` (with provided `criterion` and `thresh`) to
415
+ iteratively/greedily match and group objects/labels in consecutive images of `ys`.
416
+ To that end, matching objects are grouped together by assigning the same label id,
417
+ whereas unmatched objects are assigned a new label id.
418
+ At the end of this process, each label group will have been assigned a unique id.
419
+
420
+ Note that the label images `ys` will not be modified. Instead, they will initially
421
+ be duplicated and converted to data type `np.int32` before objects are grouped and the result
422
+ is returned. (Note that `np.int32` limits the number of label groups to at most 2147483647.)
423
+
424
+ Example
425
+ -------
426
+ import numpy as np
427
+ from stardist.data import test_image_nuclei_2d
428
+ from stardist.matching import group_matching_labels
429
+
430
+ _y = test_image_nuclei_2d(return_mask=True)[1]
431
+ labels = np.stack([_y, 2*np.roll(_y,10)], axis=0)
432
+
433
+ labels_new = group_matching_labels(labels)
434
+
435
+ Parameters
436
+ ----------
437
+ ys : np.ndarray or list/tuple of np.ndarray
438
+ list/array of integer labels (2D or 3D)
439
+
440
+ """
441
+ # check 'ys' without making a copy
442
+ len(ys) > 1 or _raise(ValueError("'ys' must have 2 or more entries"))
443
+ if isinstance(ys, np.ndarray):
444
+ _check_label_array(ys, 'ys')
445
+ ys.ndim > 1 or _raise(ValueError("'ys' must be at least 2-dimensional"))
446
+ ys_grouped = np.empty_like(ys, dtype=np.int32)
447
+ else:
448
+ all(_check_label_array(y, 'ys') for y in ys) or _raise(ValueError("'ys' must be a list of label images"))
449
+ all(y.shape==ys[0].shape for y in ys) or _raise(ValueError("all label images must have the same shape"))
450
+ ys_grouped = np.empty((len(ys),)+ys[0].shape, dtype=np.int32)
451
+
452
+ def _match_single(y_prev, y, next_id):
453
+ y = y.astype(np.int32, copy=False)
454
+ res = matching(y_prev, y, report_matches=True, thresh=thresh, criterion=criterion)
455
+ # relabel dict (for matching labels) that maps label ids from y -> y_prev
456
+ relabel = dict(reversed(res.matched_pairs[i]) for i in res.matched_tps)
457
+ y_grouped = np.zeros_like(y)
458
+ for r in regionprops(y):
459
+ m = (y[r.slice] == r.label)
460
+ if r.label in relabel:
461
+ y_grouped[r.slice][m] = relabel[r.label]
462
+ else:
463
+ y_grouped[r.slice][m] = next_id
464
+ next_id += 1
465
+ return y_grouped, next_id
466
+
467
+ ys_grouped[0] = ys[0]
468
+ next_id = ys_grouped[0].max() + 1
469
+ for i in range(len(ys)-1):
470
+ ys_grouped[i+1], next_id = _match_single(ys_grouped[i], ys[i+1], next_id)
471
+ return ys_grouped
472
+
473
+
474
+
475
+ def _shuffle_labels(y):
476
+ _check_label_array(y, 'y')
477
+ y2 = np.zeros_like(y)
478
+ ids = tuple(set(np.unique(y)) - {0})
479
+ relabel = dict(zip(ids,np.random.permutation(ids)))
480
+ for r in regionprops(y):
481
+ m = (y[r.slice] == r.label)
482
+ y2[r.slice][m] = relabel[r.label]
483
+ return y2
stardist_pkg/models/__init__.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import absolute_import, print_function
2
+
3
+ from .model2d import Config2D, StarDist2D, StarDistData2D
4
+
5
+ from csbdeep.utils import backend_channels_last
6
+ from csbdeep.utils.tf import keras_import
7
+ K = keras_import('backend')
8
+ if not backend_channels_last():
9
+ raise NotImplementedError(
10
+ "Keras is configured to use the '%s' image data format, which is currently not supported. "
11
+ "Please change it to use 'channels_last' instead: "
12
+ "https://keras.io/getting-started/faq/#where-is-the-keras-configuration-file-stored" % K.image_data_format()
13
+ )
14
+ del backend_channels_last, K
15
+
16
+ from csbdeep.models import register_model, register_aliases, clear_models_and_aliases
17
+ # register pre-trained models and aliases (TODO: replace with updatable solution)
18
+ clear_models_and_aliases(StarDist2D, StarDist3D)
19
+ register_model(StarDist2D, '2D_versatile_fluo', 'https://github.com/stardist/stardist-models/releases/download/v0.1/python_2D_versatile_fluo.zip', '8db40dacb5a1311b8d2c447ad934fb8a')
20
+ register_model(StarDist2D, '2D_versatile_he', 'https://github.com/stardist/stardist-models/releases/download/v0.1/python_2D_versatile_he.zip', 'bf34cb3c0e5b3435971e18d66778a4ec')
21
+ register_model(StarDist2D, '2D_paper_dsb2018', 'https://github.com/stardist/stardist-models/releases/download/v0.1/python_2D_paper_dsb2018.zip', '6287bf283f85c058ec3e7094b41039b5')
22
+ register_model(StarDist2D, '2D_demo', 'https://github.com/stardist/stardist-models/releases/download/v0.1/python_2D_demo.zip', '31f70402f58c50dd231ec31b4375ea2c')
23
+
24
+ register_aliases(StarDist2D, '2D_paper_dsb2018', 'DSB 2018 (from StarDist 2D paper)')
25
+ register_aliases(StarDist2D, '2D_versatile_fluo', 'Versatile (fluorescent nuclei)')
26
+ register_aliases(StarDist2D, '2D_versatile_he', 'Versatile (H&E nuclei)')
27
+ del register_model, register_aliases, clear_models_and_aliases
stardist_pkg/models/base.py ADDED
@@ -0,0 +1,1196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import print_function, unicode_literals, absolute_import, division
2
+
3
+ import numpy as np
4
+ import sys
5
+ import warnings
6
+ import math
7
+ from tqdm import tqdm
8
+ from collections import namedtuple
9
+ from pathlib import Path
10
+ import threading
11
+ import functools
12
+ import scipy.ndimage as ndi
13
+ import numbers
14
+
15
+ from csbdeep.models.base_model import BaseModel
16
+ from csbdeep.utils.tf import export_SavedModel, keras_import, IS_TF_1, CARETensorBoard
17
+
18
+ import tensorflow as tf
19
+ K = keras_import('backend')
20
+ Sequence = keras_import('utils', 'Sequence')
21
+ Adam = keras_import('optimizers', 'Adam')
22
+ ReduceLROnPlateau, TensorBoard = keras_import('callbacks', 'ReduceLROnPlateau', 'TensorBoard')
23
+
24
+ from csbdeep.utils import _raise, backend_channels_last, axes_check_and_normalize, axes_dict, load_json, save_json
25
+ from csbdeep.internals.predict import tile_iterator, total_n_tiles
26
+ from csbdeep.internals.train import RollingSequence
27
+ from csbdeep.data import Resizer
28
+
29
+ from ..sample_patches import get_valid_inds
30
+ from ..nms import _ind_prob_thresh
31
+ from ..utils import _is_power_of_2, _is_floatarray, optimize_threshold
32
+
33
+ # TODO: helper function to check if receptive field of cnn is sufficient for object sizes in GT
34
+
35
+ def generic_masked_loss(mask, loss, weights=1, norm_by_mask=True, reg_weight=0, reg_penalty=K.abs):
36
+ def _loss(y_true, y_pred):
37
+ actual_loss = K.mean(mask * weights * loss(y_true, y_pred), axis=-1)
38
+ norm_mask = (K.mean(mask) + K.epsilon()) if norm_by_mask else 1
39
+ if reg_weight > 0:
40
+ reg_loss = K.mean((1-mask) * reg_penalty(y_pred), axis=-1)
41
+ return actual_loss / norm_mask + reg_weight * reg_loss
42
+ else:
43
+ return actual_loss / norm_mask
44
+ return _loss
45
+
46
+ def masked_loss(mask, penalty, reg_weight, norm_by_mask):
47
+ loss = lambda y_true, y_pred: penalty(y_true - y_pred)
48
+ return generic_masked_loss(mask, loss, reg_weight=reg_weight, norm_by_mask=norm_by_mask)
49
+
50
+ # TODO: should we use norm_by_mask=True in the loss or only in a metric?
51
+ # previous 2D behavior was norm_by_mask=False
52
+ # same question for reg_weight? use 1e-4 (as in 3D) or 0 (as in 2D)?
53
+
54
+ def masked_loss_mae(mask, reg_weight=0, norm_by_mask=True):
55
+ return masked_loss(mask, K.abs, reg_weight=reg_weight, norm_by_mask=norm_by_mask)
56
+
57
+ def masked_loss_mse(mask, reg_weight=0, norm_by_mask=True):
58
+ return masked_loss(mask, K.square, reg_weight=reg_weight, norm_by_mask=norm_by_mask)
59
+
60
+ def masked_metric_mae(mask):
61
+ def relevant_mae(y_true, y_pred):
62
+ return masked_loss(mask, K.abs, reg_weight=0, norm_by_mask=True)(y_true, y_pred)
63
+ return relevant_mae
64
+
65
+ def masked_metric_mse(mask):
66
+ def relevant_mse(y_true, y_pred):
67
+ return masked_loss(mask, K.square, reg_weight=0, norm_by_mask=True)(y_true, y_pred)
68
+ return relevant_mse
69
+
70
+ def kld(y_true, y_pred):
71
+ y_true = K.clip(y_true, K.epsilon(), 1)
72
+ y_pred = K.clip(y_pred, K.epsilon(), 1)
73
+ return K.mean(K.binary_crossentropy(y_true, y_pred) - K.binary_crossentropy(y_true, y_true), axis=-1)
74
+
75
+
76
+ def masked_loss_iou(mask, reg_weight=0, norm_by_mask=True):
77
+ def iou_loss(y_true, y_pred):
78
+ axis = -1 if backend_channels_last() else 1
79
+ # y_pred can be negative (since not constrained) -> 'inter' can be very large for y_pred << 0
80
+ # - clipping y_pred values at 0 can lead to vanishing gradients
81
+ # - 'K.sign(y_pred)' term fixes issue by enforcing that y_pred values >= 0 always lead to larger 'inter' (lower loss)
82
+ inter = K.mean(K.sign(y_pred)*K.square(K.minimum(y_true,y_pred)), axis=axis)
83
+ union = K.mean(K.square(K.maximum(y_true,y_pred)), axis=axis)
84
+ iou = inter/(union+K.epsilon())
85
+ iou = K.expand_dims(iou,axis)
86
+ loss = 1. - iou # + 0.005*K.abs(y_true-y_pred)
87
+ return loss
88
+ return generic_masked_loss(mask, iou_loss, reg_weight=reg_weight, norm_by_mask=norm_by_mask)
89
+
90
+ def masked_metric_iou(mask, reg_weight=0, norm_by_mask=True):
91
+ def iou_metric(y_true, y_pred):
92
+ axis = -1 if backend_channels_last() else 1
93
+ y_pred = K.maximum(0., y_pred)
94
+ inter = K.mean(K.square(K.minimum(y_true,y_pred)), axis=axis)
95
+ union = K.mean(K.square(K.maximum(y_true,y_pred)), axis=axis)
96
+ iou = inter/(union+K.epsilon())
97
+ loss = K.expand_dims(iou,axis)
98
+ return loss
99
+ return generic_masked_loss(mask, iou_metric, reg_weight=reg_weight, norm_by_mask=norm_by_mask)
100
+
101
+
102
+ def weighted_categorical_crossentropy(weights, ndim):
103
+ """ ndim = (2,3) """
104
+
105
+ axis = -1 if backend_channels_last() else 1
106
+ shape = [1]*(ndim+2)
107
+ shape[axis] = len(weights)
108
+ weights = np.broadcast_to(weights, shape)
109
+ weights = K.constant(weights)
110
+
111
+ def weighted_cce(y_true, y_pred):
112
+ # ignore pixels that have y_true (prob_class) < 0
113
+ mask = K.cast(y_true>=0, K.floatx())
114
+ y_pred /= K.sum(y_pred+K.epsilon(), axis=axis, keepdims=True)
115
+ y_pred = K.clip(y_pred, K.epsilon(), 1. - K.epsilon())
116
+ loss = - K.sum(weights*mask*y_true*K.log(y_pred), axis = axis)
117
+ return loss
118
+
119
+ return weighted_cce
120
+
121
+
122
+ class StarDistDataBase(RollingSequence):
123
+
124
+ def __init__(self, X, Y, n_rays, grid, batch_size, patch_size, length,
125
+ n_classes=None, classes=None,
126
+ use_gpu=False, sample_ind_cache=True, maxfilter_patch_size=None, augmenter=None, foreground_prob=0):
127
+
128
+ super().__init__(data_size=len(X), batch_size=batch_size, length=length, shuffle=True)
129
+
130
+ if isinstance(X, (np.ndarray, tuple, list)):
131
+ X = [x.astype(np.float32, copy=False) for x in X]
132
+
133
+ # sanity checks
134
+ len(X)==len(Y) and len(X)>0 or _raise(ValueError("X and Y can't be empty and must have same length"))
135
+
136
+ if classes is None:
137
+ # set classes to None for all images (i.e. defaults to every object instance assigned the same class)
138
+ classes = (None,)*len(X)
139
+ else:
140
+ n_classes is not None or warnings.warn("Ignoring classes since n_classes is None")
141
+
142
+ len(classes)==len(X) or _raise(ValueError("X and classes must have same length"))
143
+
144
+ self.n_classes, self.classes = n_classes, classes
145
+
146
+ nD = len(patch_size)
147
+ assert nD in (2,3)
148
+ x_ndim = X[0].ndim
149
+ assert x_ndim in (nD,nD+1)
150
+
151
+ if isinstance(X, (np.ndarray, tuple, list)) and \
152
+ isinstance(Y, (np.ndarray, tuple, list)):
153
+ all(y.ndim==nD and x.ndim==x_ndim and x.shape[:nD]==y.shape for x,y in zip(X,Y)) or _raise(ValueError("images and masks should have corresponding shapes/dimensions"))
154
+ all(x.shape[:nD]>=tuple(patch_size) for x in X) or _raise(ValueError("Some images are too small for given patch_size {patch_size}".format(patch_size=patch_size)))
155
+
156
+ if x_ndim == nD:
157
+ self.n_channel = None
158
+ else:
159
+ self.n_channel = X[0].shape[-1]
160
+ if isinstance(X, (np.ndarray, tuple, list)):
161
+ assert all(x.shape[-1]==self.n_channel for x in X)
162
+
163
+ assert 0 <= foreground_prob <= 1
164
+
165
+ self.X, self.Y = X, Y
166
+ # self.batch_size = batch_size
167
+ self.n_rays = n_rays
168
+ self.patch_size = patch_size
169
+ self.ss_grid = (slice(None),) + tuple(slice(0, None, g) for g in grid)
170
+ self.grid = tuple(grid)
171
+ self.use_gpu = bool(use_gpu)
172
+ if augmenter is None:
173
+ augmenter = lambda *args: args
174
+ callable(augmenter) or _raise(ValueError("augmenter must be None or callable"))
175
+ self.augmenter = augmenter
176
+ self.foreground_prob = foreground_prob
177
+
178
+ if self.use_gpu:
179
+ from gputools import max_filter
180
+ self.max_filter = lambda y, patch_size: max_filter(y.astype(np.float32), patch_size)
181
+ else:
182
+ from scipy.ndimage.filters import maximum_filter
183
+ self.max_filter = lambda y, patch_size: maximum_filter(y, patch_size, mode='constant')
184
+
185
+ self.maxfilter_patch_size = maxfilter_patch_size if maxfilter_patch_size is not None else self.patch_size
186
+
187
+ self.sample_ind_cache = sample_ind_cache
188
+ self._ind_cache_fg = {}
189
+ self._ind_cache_all = {}
190
+ self.lock = threading.Lock()
191
+
192
+
193
+ def get_valid_inds(self, k, foreground_prob=None):
194
+ if foreground_prob is None:
195
+ foreground_prob = self.foreground_prob
196
+ foreground_only = np.random.uniform() < foreground_prob
197
+ _ind_cache = self._ind_cache_fg if foreground_only else self._ind_cache_all
198
+ if k in _ind_cache:
199
+ inds = _ind_cache[k]
200
+ else:
201
+ patch_filter = (lambda y,p: self.max_filter(y, self.maxfilter_patch_size) > 0) if foreground_only else None
202
+ inds = get_valid_inds(self.Y[k], self.patch_size, patch_filter=patch_filter)
203
+ if self.sample_ind_cache:
204
+ with self.lock:
205
+ _ind_cache[k] = inds
206
+ if foreground_only and len(inds[0])==0:
207
+ # no foreground pixels available
208
+ return self.get_valid_inds(k, foreground_prob=0)
209
+ return inds
210
+
211
+
212
+ def channels_as_tuple(self, x):
213
+ if self.n_channel is None:
214
+ return (x,)
215
+ else:
216
+ return tuple(x[...,i] for i in range(self.n_channel))
217
+
218
+
219
+
220
+ class StarDistBase(BaseModel):
221
+
222
+ def __init__(self, config, name=None, basedir='.'):
223
+ super().__init__(config=config, name=name, basedir=basedir)
224
+ threshs = dict(prob=None, nms=None)
225
+ if basedir is not None:
226
+ try:
227
+ threshs = load_json(str(self.logdir / 'thresholds.json'))
228
+ print("Loading thresholds from 'thresholds.json'.")
229
+ if threshs.get('prob') is None or not (0 < threshs.get('prob') < 1):
230
+ print("- Invalid 'prob' threshold (%s), using default value." % str(threshs.get('prob')))
231
+ threshs['prob'] = None
232
+ if threshs.get('nms') is None or not (0 < threshs.get('nms') < 1):
233
+ print("- Invalid 'nms' threshold (%s), using default value." % str(threshs.get('nms')))
234
+ threshs['nms'] = None
235
+ except FileNotFoundError:
236
+ if config is None and len(tuple(self.logdir.glob('*.h5'))) > 0:
237
+ print("Couldn't load thresholds from 'thresholds.json', using default values. "
238
+ "(Call 'optimize_thresholds' to change that.)")
239
+
240
+ self.thresholds = dict (
241
+ prob = 0.5 if threshs['prob'] is None else threshs['prob'],
242
+ nms = 0.4 if threshs['nms'] is None else threshs['nms'],
243
+ )
244
+ print("Using default values: prob_thresh={prob:g}, nms_thresh={nms:g}.".format(prob=self.thresholds.prob, nms=self.thresholds.nms))
245
+
246
+
247
+ @property
248
+ def thresholds(self):
249
+ return self._thresholds
250
+
251
+ def _is_multiclass(self):
252
+ return (self.config.n_classes is not None)
253
+
254
+ def _parse_classes_arg(self, classes, length):
255
+ """ creates a proper classes tuple from different possible "classes" arguments in model.train()
256
+
257
+ classes can be
258
+ "auto" -> all objects will be assigned to the first foreground class (unless n_classes is None)
259
+ single integer -> all objects will be assigned that class
260
+ tuple, list, ndarray -> do nothing (needs to be of given length)
261
+
262
+ returns a tuple of given length
263
+ """
264
+ if isinstance(classes, str):
265
+ classes == "auto" or _raise(ValueError(f"classes = '{classes}': only 'auto' supported as string argument for classes"))
266
+ if self.config.n_classes is None:
267
+ classes = None
268
+ elif self.config.n_classes == 1:
269
+ classes = (1,)*length
270
+ else:
271
+ raise ValueError("using classes = 'auto' for n_classes > 1 not supported")
272
+ elif isinstance(classes, (tuple, list, np.ndarray)):
273
+ len(classes) == length or _raise(ValueError(f"len(classes) should be {length}!"))
274
+ else:
275
+ raise ValueError("classes should either be 'auto' or a list of scalars/label dicts")
276
+ return classes
277
+
278
+ @thresholds.setter
279
+ def thresholds(self, d):
280
+ self._thresholds = namedtuple('Thresholds',d.keys())(*d.values())
281
+
282
+
283
+ def prepare_for_training(self, optimizer=None):
284
+ """Prepare for neural network training.
285
+
286
+ Compiles the model and creates
287
+ `Keras Callbacks <https://keras.io/callbacks/>`_ to be used for training.
288
+
289
+ Note that this method will be implicitly called once by :func:`train`
290
+ (with default arguments) if not done so explicitly beforehand.
291
+
292
+ Parameters
293
+ ----------
294
+ optimizer : obj or None
295
+ Instance of a `Keras Optimizer <https://keras.io/optimizers/>`_ to be used for training.
296
+ If ``None`` (default), uses ``Adam`` with the learning rate specified in ``config``.
297
+
298
+ """
299
+ if optimizer is None:
300
+ optimizer = Adam(self.config.train_learning_rate)
301
+
302
+ masked_dist_loss = {'mse': masked_loss_mse,
303
+ 'mae': masked_loss_mae,
304
+ 'iou': masked_loss_iou,
305
+ }[self.config.train_dist_loss]
306
+ prob_loss = 'binary_crossentropy'
307
+
308
+
309
+ def split_dist_true_mask(dist_true_mask):
310
+ return tf.split(dist_true_mask, num_or_size_splits=[self.config.n_rays,-1], axis=-1)
311
+
312
+ def dist_loss(dist_true_mask, dist_pred):
313
+ dist_true, dist_mask = split_dist_true_mask(dist_true_mask)
314
+ return masked_dist_loss(dist_mask, reg_weight=self.config.train_background_reg)(dist_true, dist_pred)
315
+
316
+ def dist_iou_metric(dist_true_mask, dist_pred):
317
+ dist_true, dist_mask = split_dist_true_mask(dist_true_mask)
318
+ return masked_metric_iou(dist_mask, reg_weight=0)(dist_true, dist_pred)
319
+
320
+ def relevant_mae(dist_true_mask, dist_pred):
321
+ dist_true, dist_mask = split_dist_true_mask(dist_true_mask)
322
+ return masked_metric_mae(dist_mask)(dist_true, dist_pred)
323
+
324
+ def relevant_mse(dist_true_mask, dist_pred):
325
+ dist_true, dist_mask = split_dist_true_mask(dist_true_mask)
326
+ return masked_metric_mse(dist_mask)(dist_true, dist_pred)
327
+
328
+
329
+ if self._is_multiclass():
330
+ prob_class_loss = weighted_categorical_crossentropy(self.config.train_class_weights, ndim=self.config.n_dim)
331
+ loss = [prob_loss, dist_loss, prob_class_loss]
332
+ else:
333
+ loss = [prob_loss, dist_loss]
334
+
335
+ self.keras_model.compile(optimizer, loss = loss,
336
+ loss_weights = list(self.config.train_loss_weights),
337
+ metrics = {'prob': kld,
338
+ 'dist': [relevant_mae, relevant_mse, dist_iou_metric]})
339
+
340
+ self.callbacks = []
341
+ if self.basedir is not None:
342
+ self.callbacks += self._checkpoint_callbacks()
343
+
344
+ if self.config.train_tensorboard:
345
+ if IS_TF_1:
346
+ self.callbacks.append(CARETensorBoard(log_dir=str(self.logdir), prefix_with_timestamp=False, n_images=3, write_images=True, prob_out=False))
347
+ else:
348
+ self.callbacks.append(TensorBoard(log_dir=str(self.logdir/'logs'), write_graph=False, profile_batch=0))
349
+
350
+ if self.config.train_reduce_lr is not None:
351
+ rlrop_params = self.config.train_reduce_lr
352
+ if 'verbose' not in rlrop_params:
353
+ rlrop_params['verbose'] = True
354
+ # TF2: add as first callback to put 'lr' in the logs for TensorBoard
355
+ self.callbacks.insert(0,ReduceLROnPlateau(**rlrop_params))
356
+
357
+ self._model_prepared = True
358
+
359
+
360
+ def _predict_setup(self, img, axes, normalizer, n_tiles, show_tile_progress, predict_kwargs):
361
+ """ Shared setup code between `predict` and `predict_sparse` """
362
+ if n_tiles is None:
363
+ n_tiles = [1]*img.ndim
364
+ try:
365
+ n_tiles = tuple(n_tiles)
366
+ img.ndim == len(n_tiles) or _raise(TypeError())
367
+ except TypeError:
368
+ raise ValueError("n_tiles must be an iterable of length %d" % img.ndim)
369
+ all(np.isscalar(t) and 1<=t and int(t)==t for t in n_tiles) or _raise(
370
+ ValueError("all values of n_tiles must be integer values >= 1"))
371
+
372
+ n_tiles = tuple(map(int,n_tiles))
373
+
374
+ axes = self._normalize_axes(img, axes)
375
+ axes_net = self.config.axes
376
+
377
+ _permute_axes = self._make_permute_axes(axes, axes_net)
378
+ x = _permute_axes(img) # x has axes_net semantics
379
+
380
+ channel = axes_dict(axes_net)['C']
381
+ self.config.n_channel_in == x.shape[channel] or _raise(ValueError())
382
+ axes_net_div_by = self._axes_div_by(axes_net)
383
+
384
+ grid = tuple(self.config.grid)
385
+ len(grid) == len(axes_net)-1 or _raise(ValueError())
386
+ grid_dict = dict(zip(axes_net.replace('C',''),grid))
387
+
388
+ normalizer = self._check_normalizer_resizer(normalizer, None)[0]
389
+ resizer = StarDistPadAndCropResizer(grid=grid_dict)
390
+
391
+ x = normalizer.before(x, axes_net)
392
+ x = resizer.before(x, axes_net, axes_net_div_by)
393
+
394
+ if not _is_floatarray(x):
395
+ warnings.warn("Predicting on non-float input... ( forgot to normalize? )")
396
+
397
+ def predict_direct(x):
398
+ ys = self.keras_model.predict(x[np.newaxis], **predict_kwargs)
399
+ return tuple(y[0] for y in ys)
400
+
401
+ def tiling_setup():
402
+ assert np.prod(n_tiles) > 1
403
+ tiling_axes = axes_net.replace('C','') # axes eligible for tiling
404
+ x_tiling_axis = tuple(axes_dict(axes_net)[a] for a in tiling_axes) # numerical axis ids for x
405
+ axes_net_tile_overlaps = self._axes_tile_overlap(axes_net)
406
+ # hack: permute tiling axis in the same way as img -> x was permuted
407
+ _n_tiles = _permute_axes(np.empty(n_tiles,bool)).shape
408
+ (all(_n_tiles[i] == 1 for i in range(x.ndim) if i not in x_tiling_axis) or
409
+ _raise(ValueError("entry of n_tiles > 1 only allowed for axes '%s'" % tiling_axes)))
410
+
411
+ sh = [s//grid_dict.get(a,1) for a,s in zip(axes_net,x.shape)]
412
+ sh[channel] = None
413
+ def create_empty_output(n_channel, dtype=np.float32):
414
+ sh[channel] = n_channel
415
+ return np.empty(sh,dtype)
416
+
417
+ if callable(show_tile_progress):
418
+ progress, _show_tile_progress = show_tile_progress, True
419
+ else:
420
+ progress, _show_tile_progress = tqdm, show_tile_progress
421
+
422
+ n_block_overlaps = [int(np.ceil(overlap/blocksize)) for overlap, blocksize
423
+ in zip(axes_net_tile_overlaps, axes_net_div_by)]
424
+
425
+ num_tiles_used = total_n_tiles(x, _n_tiles, block_sizes=axes_net_div_by, n_block_overlaps=n_block_overlaps)
426
+
427
+ tile_generator = progress(tile_iterator(x, _n_tiles, block_sizes=axes_net_div_by, n_block_overlaps=n_block_overlaps),
428
+ disable=(not _show_tile_progress), total=num_tiles_used)
429
+
430
+ return tile_generator, tuple(sh), create_empty_output
431
+
432
+ return x, axes, axes_net, axes_net_div_by, _permute_axes, resizer, n_tiles, grid, grid_dict, channel, predict_direct, tiling_setup
433
+
434
+
435
+ def _predict_generator(self, img, axes=None, normalizer=None, n_tiles=None, show_tile_progress=True, **predict_kwargs):
436
+ """Predict.
437
+
438
+ Parameters
439
+ ----------
440
+ img : :class:`numpy.ndarray`
441
+ Input image
442
+ axes : str or None
443
+ Axes of the input ``img``.
444
+ ``None`` denotes that axes of img are the same as denoted in the config.
445
+ normalizer : :class:`csbdeep.data.Normalizer` or None
446
+ (Optional) normalization of input image before prediction.
447
+ Note that the default (``None``) assumes ``img`` to be already normalized.
448
+ n_tiles : iterable or None
449
+ Out of memory (OOM) errors can occur if the input image is too large.
450
+ To avoid this problem, the input image is broken up into (overlapping) tiles
451
+ that are processed independently and re-assembled.
452
+ This parameter denotes a tuple of the number of tiles for every image axis (see ``axes``).
453
+ ``None`` denotes that no tiling should be used.
454
+ show_tile_progress: bool or callable
455
+ If boolean, indicates whether to show progress (via tqdm) during tiled prediction.
456
+ If callable, must be a drop-in replacement for tqdm.
457
+ show_tile_progress: bool
458
+ Whether to show progress during tiled prediction.
459
+ predict_kwargs: dict
460
+ Keyword arguments for ``predict`` function of Keras model.
461
+
462
+ Returns
463
+ -------
464
+ (:class:`numpy.ndarray`, :class:`numpy.ndarray`, [:class:`numpy.ndarray`])
465
+ Returns the tuple (`prob`, `dist`, [`prob_class`]) of per-pixel object probabilities and star-convex polygon/polyhedra distances.
466
+ In multiclass prediction mode, `prob_class` is the probability map for each of the 1+'n_classes' classes (first class is background)
467
+
468
+ """
469
+
470
+ x, axes, axes_net, axes_net_div_by, _permute_axes, resizer, n_tiles, grid, grid_dict, channel, predict_direct, tiling_setup = \
471
+ self._predict_setup(img, axes, normalizer, n_tiles, show_tile_progress, predict_kwargs)
472
+
473
+ if np.prod(n_tiles) > 1:
474
+ tile_generator, output_shape, create_empty_output = tiling_setup()
475
+
476
+ prob = create_empty_output(1)
477
+ dist = create_empty_output(self.config.n_rays)
478
+ if self._is_multiclass():
479
+ prob_class = create_empty_output(self.config.n_classes+1)
480
+ result = (prob, dist, prob_class)
481
+ else:
482
+ result = (prob, dist)
483
+
484
+ for tile, s_src, s_dst in tile_generator:
485
+ # predict_direct -> prob, dist, [prob_class if multi_class]
486
+ result_tile = predict_direct(tile)
487
+ # account for grid
488
+ s_src = [slice(s.start//grid_dict.get(a,1),s.stop//grid_dict.get(a,1)) for s,a in zip(s_src,axes_net)]
489
+ s_dst = [slice(s.start//grid_dict.get(a,1),s.stop//grid_dict.get(a,1)) for s,a in zip(s_dst,axes_net)]
490
+ # prob and dist have different channel dimensionality than image x
491
+ s_src[channel] = slice(None)
492
+ s_dst[channel] = slice(None)
493
+ s_src, s_dst = tuple(s_src), tuple(s_dst)
494
+ # print(s_src,s_dst)
495
+ for part, part_tile in zip(result, result_tile):
496
+ part[s_dst] = part_tile[s_src]
497
+ yield # yield None after each processed tile
498
+ else:
499
+ # predict_direct -> prob, dist, [prob_class if multi_class]
500
+ result = predict_direct(x)
501
+
502
+ result = [resizer.after(part, axes_net) for part in result]
503
+
504
+ # result = (prob, dist) for legacy or (prob, dist, prob_class) for multiclass
505
+
506
+ # prob
507
+ result[0] = np.take(result[0],0,axis=channel)
508
+ # dist
509
+ result[1] = np.maximum(1e-3, result[1]) # avoid small dist values to prevent problems with Qhull
510
+ result[1] = np.moveaxis(result[1],channel,-1)
511
+
512
+ if self._is_multiclass():
513
+ # prob_class
514
+ result[2] = np.moveaxis(result[2],channel,-1)
515
+
516
+ # last "yield" is the actual output that would have been "return"ed if this was a regular function
517
+ yield tuple(result)
518
+
519
+
520
+ @functools.wraps(_predict_generator)
521
+ def predict(self, *args, **kwargs):
522
+ # return last "yield"ed value of generator
523
+ r = None
524
+ for r in self._predict_generator(*args, **kwargs):
525
+ pass
526
+ return r
527
+
528
+
529
+ def _predict_sparse_generator(self, img, prob_thresh=None, axes=None, normalizer=None, n_tiles=None, show_tile_progress=True, b=2, **predict_kwargs):
530
+ """ Sparse version of model.predict()
531
+ Returns
532
+ -------
533
+ (prob, dist, [prob_class], points) flat list of probs, dists, (optional prob_class) and points
534
+ """
535
+ if prob_thresh is None: prob_thresh = self.thresholds.prob
536
+
537
+ x, axes, axes_net, axes_net_div_by, _permute_axes, resizer, n_tiles, grid, grid_dict, channel, predict_direct, tiling_setup = \
538
+ self._predict_setup(img, axes, normalizer, n_tiles, show_tile_progress, predict_kwargs)
539
+
540
+ def _prep(prob, dist):
541
+ prob = np.take(prob,0,axis=channel)
542
+ dist = np.moveaxis(dist,channel,-1)
543
+ dist = np.maximum(1e-3, dist)
544
+ return prob, dist
545
+
546
+ proba, dista, pointsa, prob_class = [],[],[], []
547
+
548
+ if np.prod(n_tiles) > 1:
549
+ tile_generator, output_shape, create_empty_output = tiling_setup()
550
+
551
+ sh = list(output_shape)
552
+ sh[channel] = 1;
553
+
554
+ proba, dista, pointsa, prob_classa = [], [], [], []
555
+
556
+ for tile, s_src, s_dst in tile_generator:
557
+
558
+ results_tile = predict_direct(tile)
559
+
560
+ # account for grid
561
+ s_src = [slice(s.start//grid_dict.get(a,1),s.stop//grid_dict.get(a,1)) for s,a in zip(s_src,axes_net)]
562
+ s_dst = [slice(s.start//grid_dict.get(a,1),s.stop//grid_dict.get(a,1)) for s,a in zip(s_dst,axes_net)]
563
+ s_src[channel] = slice(None)
564
+ s_dst[channel] = slice(None)
565
+ s_src, s_dst = tuple(s_src), tuple(s_dst)
566
+
567
+ prob_tile, dist_tile = results_tile[:2]
568
+ prob_tile, dist_tile = _prep(prob_tile[s_src], dist_tile[s_src])
569
+
570
+ bs = list((b if s.start==0 else -1, b if s.stop==_sh else -1) for s,_sh in zip(s_dst, sh))
571
+ bs.pop(channel)
572
+ inds = _ind_prob_thresh(prob_tile, prob_thresh, b=bs)
573
+ proba.extend(prob_tile[inds].copy())
574
+ dista.extend(dist_tile[inds].copy())
575
+ _points = np.stack(np.where(inds), axis=1)
576
+ offset = list(s.start for i,s in enumerate(s_dst))
577
+ offset.pop(channel)
578
+ _points = _points + np.array(offset).reshape((1,len(offset)))
579
+ _points = _points * np.array(self.config.grid).reshape((1,len(self.config.grid)))
580
+ pointsa.extend(_points)
581
+
582
+ if self._is_multiclass():
583
+ p = results_tile[2][s_src].copy()
584
+ p = np.moveaxis(p,channel,-1)
585
+ prob_classa.extend(p[inds])
586
+ yield # yield None after each processed tile
587
+
588
+ else:
589
+ # predict_direct -> prob, dist, [prob_class if multi_class]
590
+ results = predict_direct(x)
591
+ prob, dist = results[:2]
592
+ prob, dist = _prep(prob, dist)
593
+ inds = _ind_prob_thresh(prob, prob_thresh, b=b)
594
+ proba = prob[inds].copy()
595
+ dista = dist[inds].copy()
596
+ _points = np.stack(np.where(inds), axis=1)
597
+ pointsa = (_points * np.array(self.config.grid).reshape((1,len(self.config.grid))))
598
+
599
+ if self._is_multiclass():
600
+ p = np.moveaxis(results[2],channel,-1)
601
+ prob_classa = p[inds].copy()
602
+
603
+
604
+ proba = np.asarray(proba)
605
+ dista = np.asarray(dista).reshape((-1,self.config.n_rays))
606
+ pointsa = np.asarray(pointsa).reshape((-1,self.config.n_dim))
607
+
608
+ idx = resizer.filter_points(x.ndim, pointsa, axes_net)
609
+ proba = proba[idx]
610
+ dista = dista[idx]
611
+ pointsa = pointsa[idx]
612
+
613
+ # last "yield" is the actual output that would have been "return"ed if this was a regular function
614
+ if self._is_multiclass():
615
+ prob_classa = np.asarray(prob_classa).reshape((-1,self.config.n_classes+1))
616
+ prob_classa = prob_classa[idx]
617
+ yield proba, dista, prob_classa, pointsa
618
+ else:
619
+ prob_classa = None
620
+ yield proba, dista, pointsa
621
+
622
+
623
+ @functools.wraps(_predict_sparse_generator)
624
+ def predict_sparse(self, *args, **kwargs):
625
+ # return last "yield"ed value of generator
626
+ r = None
627
+ for r in self._predict_sparse_generator(*args, **kwargs):
628
+ pass
629
+ return r
630
+
631
+
632
+ def _predict_instances_generator(self, img, axes=None, normalizer=None,
633
+ sparse=True,
634
+ prob_thresh=None, nms_thresh=None,
635
+ scale=None,
636
+ n_tiles=None, show_tile_progress=True,
637
+ verbose=False,
638
+ return_labels=True,
639
+ predict_kwargs=None, nms_kwargs=None,
640
+ overlap_label=None, return_predict=False):
641
+ """Predict instance segmentation from input image.
642
+
643
+ Parameters
644
+ ----------
645
+ img : :class:`numpy.ndarray`
646
+ Input image
647
+ axes : str or None
648
+ Axes of the input ``img``.
649
+ ``None`` denotes that axes of img are the same as denoted in the config.
650
+ normalizer : :class:`csbdeep.data.Normalizer` or None
651
+ (Optional) normalization of input image before prediction.
652
+ Note that the default (``None``) assumes ``img`` to be already normalized.
653
+ sparse: bool
654
+ If true, aggregate probabilities/distances sparsely during tiled
655
+ prediction to save memory (recommended).
656
+ prob_thresh : float or None
657
+ Consider only object candidates from pixels with predicted object probability
658
+ above this threshold (also see `optimize_thresholds`).
659
+ nms_thresh : float or None
660
+ Perform non-maximum suppression that considers two objects to be the same
661
+ when their area/surface overlap exceeds this threshold (also see `optimize_thresholds`).
662
+ scale: None or float or iterable
663
+ Scale the input image internally by this factor and rescale the output accordingly.
664
+ All spatial axes (X,Y,Z) will be scaled if a scalar value is provided.
665
+ Alternatively, multiple scale values (compatible with input `axes`) can be used
666
+ for more fine-grained control (scale values for non-spatial axes must be 1).
667
+ n_tiles : iterable or None
668
+ Out of memory (OOM) errors can occur if the input image is too large.
669
+ To avoid this problem, the input image is broken up into (overlapping) tiles
670
+ that are processed independently and re-assembled.
671
+ This parameter denotes a tuple of the number of tiles for every image axis (see ``axes``).
672
+ ``None`` denotes that no tiling should be used.
673
+ show_tile_progress: bool
674
+ Whether to show progress during tiled prediction.
675
+ verbose: bool
676
+ Whether to print some info messages.
677
+ return_labels: bool
678
+ Whether to create a label image, otherwise return None in its place.
679
+ predict_kwargs: dict
680
+ Keyword arguments for ``predict`` function of Keras model.
681
+ nms_kwargs: dict
682
+ Keyword arguments for non-maximum suppression.
683
+ overlap_label: scalar or None
684
+ if not None, label the regions where polygons overlap with that value
685
+ return_predict: bool
686
+ Also return the outputs of :func:`predict` (in a separate tuple)
687
+ If True, implies sparse = False
688
+
689
+ Returns
690
+ -------
691
+ (:class:`numpy.ndarray`, dict), (optional: return tuple of :func:`predict`)
692
+ Returns a tuple of the label instances image and also
693
+ a dictionary with the details (coordinates, etc.) of all remaining polygons/polyhedra.
694
+
695
+ """
696
+ if predict_kwargs is None:
697
+ predict_kwargs = {}
698
+ if nms_kwargs is None:
699
+ nms_kwargs = {}
700
+
701
+ if return_predict and sparse:
702
+ sparse = False
703
+ warnings.warn("Setting sparse to False because return_predict is True")
704
+
705
+ nms_kwargs.setdefault("verbose", verbose)
706
+
707
+ _axes = self._normalize_axes(img, axes)
708
+ _axes_net = self.config.axes
709
+ _permute_axes = self._make_permute_axes(_axes, _axes_net)
710
+ _shape_inst = tuple(s for s,a in zip(_permute_axes(img).shape, _axes_net) if a != 'C')
711
+
712
+ if scale is not None:
713
+ if isinstance(scale, numbers.Number):
714
+ scale = tuple(scale if a in 'XYZ' else 1 for a in _axes)
715
+ scale = tuple(scale)
716
+ len(scale) == len(_axes) or _raise(ValueError(f"scale {scale} must be of length {len(_axes)}, i.e. one value for each of the axes {_axes}"))
717
+ for s,a in zip(scale,_axes):
718
+ s > 0 or _raise(ValueError("scale values must be greater than 0"))
719
+ (s in (1,None) or a in 'XYZ') or warnings.warn(f"replacing scale value {s} for non-spatial axis {a} with 1")
720
+ scale = tuple(s if a in 'XYZ' else 1 for s,a in zip(scale,_axes))
721
+ verbose and print(f"scaling image by factors {scale} for axes {_axes}")
722
+ img = ndi.zoom(img, scale, order=1)
723
+
724
+ yield 'predict' # indicate that prediction is starting
725
+ res = None
726
+ if sparse:
727
+ for res in self._predict_sparse_generator(img, axes=axes, normalizer=normalizer, n_tiles=n_tiles,
728
+ prob_thresh=prob_thresh, show_tile_progress=show_tile_progress, **predict_kwargs):
729
+ if res is None:
730
+ yield 'tile' # yield 'tile' each time a tile has been processed
731
+ else:
732
+ for res in self._predict_generator(img, axes=axes, normalizer=normalizer, n_tiles=n_tiles,
733
+ show_tile_progress=show_tile_progress, **predict_kwargs):
734
+ if res is None:
735
+ yield 'tile' # yield 'tile' each time a tile has been processed
736
+ res = tuple(res) + (None,)
737
+
738
+ if self._is_multiclass():
739
+ prob, dist, prob_class, points = res
740
+ else:
741
+ prob, dist, points = res
742
+ prob_class = None
743
+
744
+ yield 'nms' # indicate that non-maximum suppression is starting
745
+ res_instances = self._instances_from_prediction(_shape_inst, prob, dist,
746
+ points=points,
747
+ prob_class=prob_class,
748
+ prob_thresh=prob_thresh,
749
+ nms_thresh=nms_thresh,
750
+ scale=(None if scale is None else dict(zip(_axes,scale))),
751
+ return_labels=return_labels,
752
+ overlap_label=overlap_label,
753
+ **nms_kwargs)
754
+
755
+ # last "yield" is the actual output that would have been "return"ed if this was a regular function
756
+ if return_predict:
757
+ yield res_instances, tuple(res[:-1])
758
+ else:
759
+ yield res_instances
760
+
761
+
762
+ @functools.wraps(_predict_instances_generator)
763
+ def predict_instances(self, *args, **kwargs):
764
+ # the reason why the actual computation happens as a generator function
765
+ # (in '_predict_instances_generator') is that the generator is called
766
+ # from the stardist napari plugin, which has its benefits regarding
767
+ # control flow and progress display. however, typical use cases should
768
+ # almost always use this function ('predict_instances'), and shouldn't
769
+ # even notice (thanks to @functools.wraps) that it wraps the generator
770
+ # function. note that similar reasoning applies to 'predict' and
771
+ # 'predict_sparse'.
772
+
773
+ # return last "yield"ed value of generator
774
+ r = None
775
+ for r in self._predict_instances_generator(*args, **kwargs):
776
+ pass
777
+ return r
778
+
779
+
780
+ # def _predict_instances_old(self, img, axes=None, normalizer=None,
781
+ # sparse = False,
782
+ # prob_thresh=None, nms_thresh=None,
783
+ # n_tiles=None, show_tile_progress=True,
784
+ # verbose = False,
785
+ # predict_kwargs=None, nms_kwargs=None, overlap_label=None):
786
+ # """
787
+ # old version, should be removed....
788
+ # """
789
+ # if predict_kwargs is None:
790
+ # predict_kwargs = {}
791
+ # if nms_kwargs is None:
792
+ # nms_kwargs = {}
793
+
794
+ # nms_kwargs.setdefault("verbose", verbose)
795
+
796
+ # _axes = self._normalize_axes(img, axes)
797
+ # _axes_net = self.config.axes
798
+ # _permute_axes = self._make_permute_axes(_axes, _axes_net)
799
+ # _shape_inst = tuple(s for s,a in zip(_permute_axes(img).shape, _axes_net) if a != 'C')
800
+
801
+
802
+ # res = self.predict(img, axes=axes, normalizer=normalizer,
803
+ # n_tiles=n_tiles,
804
+ # show_tile_progress=show_tile_progress,
805
+ # **predict_kwargs)
806
+
807
+ # res = tuple(res) + (None,)
808
+
809
+ # if self._is_multiclass():
810
+ # prob, dist, prob_class, points = res
811
+ # else:
812
+ # prob, dist, points = res
813
+ # prob_class = None
814
+
815
+
816
+ # return self._instances_from_prediction_old(_shape_inst, prob, dist,
817
+ # points = points,
818
+ # prob_class = prob_class,
819
+ # prob_thresh=prob_thresh,
820
+ # nms_thresh=nms_thresh,
821
+ # overlap_label=overlap_label,
822
+ # **nms_kwargs)
823
+
824
+
825
+ def predict_instances_big(self, img, axes, block_size, min_overlap, context=None,
826
+ labels_out=None, labels_out_dtype=np.int32, show_progress=True, **kwargs):
827
+ """Predict instance segmentation from very large input images.
828
+
829
+ Intended to be used when `predict_instances` cannot be used due to memory limitations.
830
+ This function will break the input image into blocks and process them individually
831
+ via `predict_instances` and assemble all the partial results. If used as intended, the result
832
+ should be the same as if `predict_instances` was used directly on the whole image.
833
+
834
+ **Important**: The crucial assumption is that all predicted object instances are smaller than
835
+ the provided `min_overlap`. Also, it must hold that: min_overlap + 2*context < block_size.
836
+
837
+ Example
838
+ -------
839
+ >>> img.shape
840
+ (20000, 20000)
841
+ >>> labels, polys = model.predict_instances_big(img, axes='YX', block_size=4096,
842
+ min_overlap=128, context=128, n_tiles=(4,4))
843
+
844
+ Parameters
845
+ ----------
846
+ img: :class:`numpy.ndarray` or similar
847
+ Input image
848
+ axes: str
849
+ Axes of the input ``img`` (such as 'YX', 'ZYX', 'YXC', etc.)
850
+ block_size: int or iterable of int
851
+ Process input image in blocks of the provided shape.
852
+ (If a scalar value is given, it is used for all spatial image dimensions.)
853
+ min_overlap: int or iterable of int
854
+ Amount of guaranteed overlap between blocks.
855
+ (If a scalar value is given, it is used for all spatial image dimensions.)
856
+ context: int or iterable of int, or None
857
+ Amount of image context on all sides of a block, which is discarded.
858
+ If None, uses an automatic estimate that should work in many cases.
859
+ (If a scalar value is given, it is used for all spatial image dimensions.)
860
+ labels_out: :class:`numpy.ndarray` or similar, or None, or False
861
+ numpy array or similar (must be of correct shape) to which the label image is written.
862
+ If None, will allocate a numpy array of the correct shape and data type ``labels_out_dtype``.
863
+ If False, will not write the label image (useful if only the dictionary is needed).
864
+ labels_out_dtype: str or dtype
865
+ Data type of returned label image if ``labels_out=None`` (has no effect otherwise).
866
+ show_progress: bool
867
+ Show progress bar for block processing.
868
+ kwargs: dict
869
+ Keyword arguments for ``predict_instances``.
870
+
871
+ Returns
872
+ -------
873
+ (:class:`numpy.ndarray` or False, dict)
874
+ Returns the label image and a dictionary with the details (coordinates, etc.) of the polygons/polyhedra.
875
+
876
+ """
877
+ from ..big import _grid_divisible, BlockND, OBJECT_KEYS#, repaint_labels
878
+ from ..matching import relabel_sequential
879
+
880
+ n = img.ndim
881
+ axes = axes_check_and_normalize(axes, length=n)
882
+ grid = self._axes_div_by(axes)
883
+ axes_out = self._axes_out.replace('C','')
884
+ shape_dict = dict(zip(axes,img.shape))
885
+ shape_out = tuple(shape_dict[a] for a in axes_out)
886
+
887
+ if context is None:
888
+ context = self._axes_tile_overlap(axes)
889
+
890
+ if np.isscalar(block_size): block_size = n*[block_size]
891
+ if np.isscalar(min_overlap): min_overlap = n*[min_overlap]
892
+ if np.isscalar(context): context = n*[context]
893
+ block_size, min_overlap, context = list(block_size), list(min_overlap), list(context)
894
+ assert n == len(block_size) == len(min_overlap) == len(context)
895
+
896
+ if 'C' in axes:
897
+ # single block for channel axis
898
+ i = axes_dict(axes)['C']
899
+ # if (block_size[i], min_overlap[i], context[i]) != (None, None, None):
900
+ # print("Ignoring values of 'block_size', 'min_overlap', and 'context' for channel axis " +
901
+ # "(set to 'None' to avoid this warning).", file=sys.stderr, flush=True)
902
+ block_size[i] = img.shape[i]
903
+ min_overlap[i] = context[i] = 0
904
+
905
+ block_size = tuple(_grid_divisible(g, v, name='block_size', verbose=False) for v,g,a in zip(block_size, grid,axes))
906
+ min_overlap = tuple(_grid_divisible(g, v, name='min_overlap', verbose=False) for v,g,a in zip(min_overlap,grid,axes))
907
+ context = tuple(_grid_divisible(g, v, name='context', verbose=False) for v,g,a in zip(context, grid,axes))
908
+
909
+ # print(f"input: shape {img.shape} with axes {axes}")
910
+ print(f'effective: block_size={block_size}, min_overlap={min_overlap}, context={context}', flush=True)
911
+
912
+ for a,c,o in zip(axes,context,self._axes_tile_overlap(axes)):
913
+ if c < o:
914
+ print(f"{a}: context of {c} is small, recommended to use at least {o}", flush=True)
915
+
916
+ # create block cover
917
+ blocks = BlockND.cover(img.shape, axes, block_size, min_overlap, context, grid)
918
+
919
+ if np.isscalar(labels_out) and bool(labels_out) is False:
920
+ labels_out = None
921
+ else:
922
+ if labels_out is None:
923
+ labels_out = np.zeros(shape_out, dtype=labels_out_dtype)
924
+ else:
925
+ labels_out.shape == shape_out or _raise(ValueError(f"'labels_out' must have shape {shape_out} (axes {axes_out})."))
926
+
927
+ polys_all = {}
928
+ # problem_ids = []
929
+ label_offset = 1
930
+
931
+ kwargs_override = dict(axes=axes, overlap_label=None, return_labels=True, return_predict=False)
932
+ if show_progress:
933
+ kwargs_override['show_tile_progress'] = False # disable progress for predict_instances
934
+ for k,v in kwargs_override.items():
935
+ if k in kwargs: print(f"changing '{k}' from {kwargs[k]} to {v}", flush=True)
936
+ kwargs[k] = v
937
+
938
+ blocks = tqdm(blocks, disable=(not show_progress))
939
+ # actual computation
940
+ for block in blocks:
941
+ labels, polys = self.predict_instances(block.read(img, axes=axes), **kwargs)
942
+ labels = block.crop_context(labels, axes=axes_out)
943
+ labels, polys = block.filter_objects(labels, polys, axes=axes_out)
944
+ # TODO: relabel_sequential is not very memory-efficient (will allocate memory proportional to label_offset)
945
+ # this should not change the order of labels
946
+ labels = relabel_sequential(labels, label_offset)[0]
947
+
948
+ # labels, fwd_map, _ = relabel_sequential(labels, label_offset)
949
+ # if len(incomplete) > 0:
950
+ # problem_ids.extend([fwd_map[i] for i in incomplete])
951
+ # if show_progress:
952
+ # blocks.set_postfix_str(f"found {len(problem_ids)} problematic {'object' if len(problem_ids)==1 else 'objects'}")
953
+ if labels_out is not None:
954
+ block.write(labels_out, labels, axes=axes_out)
955
+
956
+ for k,v in polys.items():
957
+ polys_all.setdefault(k,[]).append(v)
958
+
959
+ label_offset += len(polys['prob'])
960
+ del labels
961
+
962
+ polys_all = {k: (np.concatenate(v) if k in OBJECT_KEYS else v[0]) for k,v in polys_all.items()}
963
+
964
+ # if labels_out is not None and len(problem_ids) > 0:
965
+ # # if show_progress:
966
+ # # blocks.write('')
967
+ # # print(f"Found {len(problem_ids)} objects that violate the 'min_overlap' assumption.", file=sys.stderr, flush=True)
968
+ # repaint_labels(labels_out, problem_ids, polys_all, show_progress=False)
969
+
970
+ return labels_out, polys_all#, tuple(problem_ids)
971
+
972
+
973
+ def optimize_thresholds(self, X_val, Y_val, nms_threshs=[0.3,0.4,0.5], iou_threshs=[0.3,0.5,0.7], predict_kwargs=None, optimize_kwargs=None, save_to_json=True):
974
+ """Optimize two thresholds (probability, NMS overlap) necessary for predicting object instances.
975
+
976
+ Note that the default thresholds yield good results in many cases, but optimizing
977
+ the thresholds for a particular dataset can further improve performance.
978
+
979
+ The optimized thresholds are automatically used for all further predictions
980
+ and also written to the model directory.
981
+
982
+ See ``utils.optimize_threshold`` for details and possible choices for ``optimize_kwargs``.
983
+
984
+ Parameters
985
+ ----------
986
+ X_val : list of ndarray
987
+ (Validation) input images (must be normalized) to use for threshold tuning.
988
+ Y_val : list of ndarray
989
+ (Validation) label images to use for threshold tuning.
990
+ nms_threshs : list of float
991
+ List of overlap thresholds to be considered for NMS.
992
+ For each value in this list, optimization is run to find a corresponding prob_thresh value.
993
+ iou_threshs : list of float
994
+ List of intersection over union (IOU) thresholds for which
995
+ the (average) matching performance is considered to tune the thresholds.
996
+ predict_kwargs: dict
997
+ Keyword arguments for ``predict`` function of this class.
998
+ (If not provided, will guess value for `n_tiles` to prevent out of memory errors.)
999
+ optimize_kwargs: dict
1000
+ Keyword arguments for ``utils.optimize_threshold`` function.
1001
+
1002
+ """
1003
+ if predict_kwargs is None:
1004
+ predict_kwargs = {}
1005
+ if optimize_kwargs is None:
1006
+ optimize_kwargs = {}
1007
+
1008
+ def _predict_kwargs(x):
1009
+ if 'n_tiles' in predict_kwargs:
1010
+ return predict_kwargs
1011
+ else:
1012
+ return {**predict_kwargs, 'n_tiles': self._guess_n_tiles(x), 'show_tile_progress': False}
1013
+
1014
+ # only take first two elements of predict in case multi class is activated
1015
+ Yhat_val = [self.predict(x, **_predict_kwargs(x))[:2] for x in X_val]
1016
+
1017
+ opt_prob_thresh, opt_measure, opt_nms_thresh = None, -np.inf, None
1018
+ for _opt_nms_thresh in nms_threshs:
1019
+ _opt_prob_thresh, _opt_measure = optimize_threshold(Y_val, Yhat_val, model=self, nms_thresh=_opt_nms_thresh, iou_threshs=iou_threshs, **optimize_kwargs)
1020
+ if _opt_measure > opt_measure:
1021
+ opt_prob_thresh, opt_measure, opt_nms_thresh = _opt_prob_thresh, _opt_measure, _opt_nms_thresh
1022
+ opt_threshs = dict(prob=opt_prob_thresh, nms=opt_nms_thresh)
1023
+
1024
+ self.thresholds = opt_threshs
1025
+ print(end='', file=sys.stderr, flush=True)
1026
+ print("Using optimized values: prob_thresh={prob:g}, nms_thresh={nms:g}.".format(prob=self.thresholds.prob, nms=self.thresholds.nms))
1027
+ if save_to_json and self.basedir is not None:
1028
+ print("Saving to 'thresholds.json'.")
1029
+ save_json(opt_threshs, str(self.logdir / 'thresholds.json'))
1030
+ return opt_threshs
1031
+
1032
+
1033
+ def _guess_n_tiles(self, img):
1034
+ axes = self._normalize_axes(img, axes=None)
1035
+ shape = list(img.shape)
1036
+ if 'C' in axes:
1037
+ del shape[axes_dict(axes)['C']]
1038
+ b = self.config.train_batch_size**(1.0/self.config.n_dim)
1039
+ n_tiles = [int(np.ceil(s/(p*b))) for s,p in zip(shape,self.config.train_patch_size)]
1040
+ if 'C' in axes:
1041
+ n_tiles.insert(axes_dict(axes)['C'],1)
1042
+ return tuple(n_tiles)
1043
+
1044
+
1045
+ def _normalize_axes(self, img, axes):
1046
+ if axes is None:
1047
+ axes = self.config.axes
1048
+ assert 'C' in axes
1049
+ if img.ndim == len(axes)-1 and self.config.n_channel_in == 1:
1050
+ # img has no dedicated channel axis, but 'C' always part of config axes
1051
+ axes = axes.replace('C','')
1052
+ return axes_check_and_normalize(axes, img.ndim)
1053
+
1054
+
1055
+ def _compute_receptive_field(self, img_size=None):
1056
+ # TODO: good enough?
1057
+ from scipy.ndimage import zoom
1058
+ if img_size is None:
1059
+ img_size = tuple(g*(128 if self.config.n_dim==2 else 64) for g in self.config.grid)
1060
+ if np.isscalar(img_size):
1061
+ img_size = (img_size,) * self.config.n_dim
1062
+ img_size = tuple(img_size)
1063
+ # print(img_size)
1064
+ assert all(_is_power_of_2(s) for s in img_size)
1065
+ mid = tuple(s//2 for s in img_size)
1066
+ x = np.zeros((1,)+img_size+(self.config.n_channel_in,), dtype=np.float32)
1067
+ z = np.zeros_like(x)
1068
+ x[(0,)+mid+(slice(None),)] = 1
1069
+ y = self.keras_model.predict(x)[0][0,...,0]
1070
+ y0 = self.keras_model.predict(z)[0][0,...,0]
1071
+ grid = tuple((np.array(x.shape[1:-1])/np.array(y.shape)).astype(int))
1072
+ assert grid == self.config.grid
1073
+ y = zoom(y, grid,order=0)
1074
+ y0 = zoom(y0,grid,order=0)
1075
+ ind = np.where(np.abs(y-y0)>0)
1076
+ return [(m-np.min(i), np.max(i)-m) for (m,i) in zip(mid,ind)]
1077
+
1078
+
1079
+ def _axes_tile_overlap(self, query_axes):
1080
+ query_axes = axes_check_and_normalize(query_axes)
1081
+ try:
1082
+ self._tile_overlap
1083
+ except AttributeError:
1084
+ self._tile_overlap = self._compute_receptive_field()
1085
+ overlap = dict(zip(
1086
+ self.config.axes.replace('C',''),
1087
+ tuple(max(rf) for rf in self._tile_overlap)
1088
+ ))
1089
+ return tuple(overlap.get(a,0) for a in query_axes)
1090
+
1091
+
1092
+ def export_TF(self, fname=None, single_output=True, upsample_grid=True):
1093
+ """Export model to TensorFlow's SavedModel format that can be used e.g. in the Fiji plugin
1094
+
1095
+ Parameters
1096
+ ----------
1097
+ fname : str
1098
+ Path of the zip file to store the model
1099
+ If None, the default path "<modeldir>/TF_SavedModel.zip" is used
1100
+ single_output: bool
1101
+ If set, concatenates the two model outputs into a single output (note: this is currently mandatory for further use in Fiji)
1102
+ upsample_grid: bool
1103
+ If set, upsamples the output to the input shape (note: this is currently mandatory for further use in Fiji)
1104
+ """
1105
+ Concatenate, UpSampling2D, UpSampling3D, Conv2DTranspose, Conv3DTranspose = keras_import('layers', 'Concatenate', 'UpSampling2D', 'UpSampling3D', 'Conv2DTranspose', 'Conv3DTranspose')
1106
+ Model = keras_import('models', 'Model')
1107
+
1108
+ if self.basedir is None and fname is None:
1109
+ raise ValueError("Need explicit 'fname', since model directory not available (basedir=None).")
1110
+
1111
+ if self._is_multiclass():
1112
+ warnings.warn("multi-class mode not supported yet, removing classification output from exported model")
1113
+
1114
+ grid = self.config.grid
1115
+ prob = self.keras_model.outputs[0]
1116
+ dist = self.keras_model.outputs[1]
1117
+ assert self.config.n_dim in (2,3)
1118
+
1119
+ if upsample_grid and any(g>1 for g in grid):
1120
+ # CSBDeep Fiji plugin needs same size input/output
1121
+ # -> we need to upsample the outputs if grid > (1,1)
1122
+ # note: upsampling prob with a transposed convolution creates sparse
1123
+ # prob output with less candidates than with standard upsampling
1124
+ conv_transpose = Conv2DTranspose if self.config.n_dim==2 else Conv3DTranspose
1125
+ upsampling = UpSampling2D if self.config.n_dim==2 else UpSampling3D
1126
+ prob = conv_transpose(1, (1,)*self.config.n_dim,
1127
+ strides=grid, padding='same',
1128
+ kernel_initializer='ones', use_bias=False)(prob)
1129
+ dist = upsampling(grid)(dist)
1130
+
1131
+ inputs = self.keras_model.inputs[0]
1132
+ outputs = Concatenate()([prob,dist]) if single_output else [prob,dist]
1133
+ csbdeep_model = Model(inputs, outputs)
1134
+
1135
+ fname = (self.logdir / 'TF_SavedModel.zip') if fname is None else Path(fname)
1136
+ export_SavedModel(csbdeep_model, str(fname))
1137
+ return csbdeep_model
1138
+
1139
+
1140
+
1141
+ class StarDistPadAndCropResizer(Resizer):
1142
+
1143
+ # TODO: check correctness
1144
+ def __init__(self, grid, mode='reflect', **kwargs):
1145
+ assert isinstance(grid, dict)
1146
+ self.mode = mode
1147
+ self.grid = grid
1148
+ self.kwargs = kwargs
1149
+
1150
+
1151
+ def before(self, x, axes, axes_div_by):
1152
+ assert all(a%g==0 for g,a in zip((self.grid.get(a,1) for a in axes), axes_div_by))
1153
+ axes = axes_check_and_normalize(axes,x.ndim)
1154
+ def _split(v):
1155
+ return 0, v # only pad at the end
1156
+ self.pad = {
1157
+ a : _split((div_n-s%div_n)%div_n)
1158
+ for a, div_n, s in zip(axes, axes_div_by, x.shape)
1159
+ }
1160
+ x_pad = np.pad(x, tuple(self.pad[a] for a in axes), mode=self.mode, **self.kwargs)
1161
+ self.padded_shape = dict(zip(axes,x_pad.shape))
1162
+ if 'C' in self.padded_shape: del self.padded_shape['C']
1163
+ return x_pad
1164
+
1165
+
1166
+ def after(self, x, axes):
1167
+ # axes can include 'C', which may not have been present in before()
1168
+ axes = axes_check_and_normalize(axes,x.ndim)
1169
+ assert all(s_pad == s * g for s,s_pad,g in zip(x.shape,
1170
+ (self.padded_shape.get(a,_s) for a,_s in zip(axes,x.shape)),
1171
+ (self.grid.get(a,1) for a in axes)))
1172
+ # print(self.padded_shape)
1173
+ # print(self.pad)
1174
+ # print(self.grid)
1175
+ crop = tuple (
1176
+ slice(0, -(math.floor(p[1]/g)) if p[1]>=g else None)
1177
+ for p,g in zip((self.pad.get(a,(0,0)) for a in axes),(self.grid.get(a,1) for a in axes))
1178
+ )
1179
+ # print(crop)
1180
+ return x[crop]
1181
+
1182
+
1183
+ def filter_points(self, ndim, points, axes):
1184
+ """ returns indices of points inside crop region """
1185
+ assert points.ndim==2
1186
+ axes = axes_check_and_normalize(axes,ndim)
1187
+
1188
+ bounds = np.array(tuple(self.padded_shape[a]-self.pad[a][1] for a in axes if a.lower() in ('z','y','x')))
1189
+ idx = np.where(np.all(points< bounds, 1))
1190
+ return idx
1191
+
1192
+
1193
+
1194
+ def _tf_version_at_least(version_string="1.0.0"):
1195
+ from packaging import version
1196
+ return version.parse(tf.__version__) >= version.parse(version_string)
stardist_pkg/models/model2d.py ADDED
@@ -0,0 +1,570 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import print_function, unicode_literals, absolute_import, division
2
+
3
+ import numpy as np
4
+ import warnings
5
+ import math
6
+ from tqdm import tqdm
7
+
8
+ from csbdeep.models import BaseConfig
9
+ from csbdeep.internals.blocks import unet_block
10
+ from csbdeep.utils import _raise, backend_channels_last, axes_check_and_normalize, axes_dict
11
+ from csbdeep.utils.tf import keras_import, IS_TF_1, CARETensorBoard, CARETensorBoardImage
12
+ from skimage.segmentation import clear_border
13
+ from skimage.measure import regionprops
14
+ from scipy.ndimage import zoom
15
+ from distutils.version import LooseVersion
16
+
17
+ keras = keras_import()
18
+ K = keras_import('backend')
19
+ Input, Conv2D, MaxPooling2D = keras_import('layers', 'Input', 'Conv2D', 'MaxPooling2D')
20
+ Model = keras_import('models', 'Model')
21
+
22
+ from .base import StarDistBase, StarDistDataBase, _tf_version_at_least
23
+ from ..sample_patches import sample_patches
24
+ from ..utils import edt_prob, _normalize_grid, mask_to_categorical
25
+ from ..geometry import star_dist, dist_to_coord, polygons_to_label
26
+ from ..nms import non_maximum_suppression, non_maximum_suppression_sparse
27
+
28
+
29
+ class StarDistData2D(StarDistDataBase):
30
+
31
+ def __init__(self, X, Y, batch_size, n_rays, length,
32
+ n_classes=None, classes=None,
33
+ patch_size=(256,256), b=32, grid=(1,1), shape_completion=False, augmenter=None, foreground_prob=0, **kwargs):
34
+
35
+ super().__init__(X=X, Y=Y, n_rays=n_rays, grid=grid,
36
+ n_classes=n_classes, classes=classes,
37
+ batch_size=batch_size, patch_size=patch_size, length=length,
38
+ augmenter=augmenter, foreground_prob=foreground_prob, **kwargs)
39
+
40
+ self.shape_completion = bool(shape_completion)
41
+ if self.shape_completion and b > 0:
42
+ self.b = slice(b,-b),slice(b,-b)
43
+ else:
44
+ self.b = slice(None),slice(None)
45
+
46
+ self.sd_mode = 'opencl' if self.use_gpu else 'cpp'
47
+
48
+
49
+ def __getitem__(self, i):
50
+ idx = self.batch(i)
51
+ arrays = [sample_patches((self.Y[k],) + self.channels_as_tuple(self.X[k]),
52
+ patch_size=self.patch_size, n_samples=1,
53
+ valid_inds=self.get_valid_inds(k)) for k in idx]
54
+
55
+ if self.n_channel is None:
56
+ X, Y = list(zip(*[(x[0][self.b],y[0]) for y,x in arrays]))
57
+ else:
58
+ X, Y = list(zip(*[(np.stack([_x[0] for _x in x],axis=-1)[self.b], y[0]) for y,*x in arrays]))
59
+
60
+ X, Y = tuple(zip(*tuple(self.augmenter(_x, _y) for _x, _y in zip(X,Y))))
61
+
62
+
63
+ prob = np.stack([edt_prob(lbl[self.b][self.ss_grid[1:3]]) for lbl in Y])
64
+ # prob = np.stack([edt_prob(lbl[self.b]) for lbl in Y])
65
+ # prob = prob[self.ss_grid]
66
+
67
+ if self.shape_completion:
68
+ Y_cleared = [clear_border(lbl) for lbl in Y]
69
+ _dist = np.stack([star_dist(lbl,self.n_rays,mode=self.sd_mode)[self.b+(slice(None),)] for lbl in Y_cleared])
70
+ dist = _dist[self.ss_grid]
71
+ dist_mask = np.stack([edt_prob(lbl[self.b][self.ss_grid[1:3]]) for lbl in Y_cleared])
72
+ else:
73
+ # directly subsample with grid
74
+ dist = np.stack([star_dist(lbl,self.n_rays,mode=self.sd_mode, grid=self.grid) for lbl in Y])
75
+ dist_mask = prob
76
+
77
+ X = np.stack(X)
78
+ if X.ndim == 3: # input image has no channel axis
79
+ X = np.expand_dims(X,-1)
80
+ prob = np.expand_dims(prob,-1)
81
+ dist_mask = np.expand_dims(dist_mask,-1)
82
+
83
+ # subsample wth given grid
84
+ # dist_mask = dist_mask[self.ss_grid]
85
+ # prob = prob[self.ss_grid]
86
+
87
+ # append dist_mask to dist as additional channel
88
+ # dist_and_mask = np.concatenate([dist,dist_mask],axis=-1)
89
+ # faster than concatenate
90
+ dist_and_mask = np.empty(dist.shape[:-1]+(self.n_rays+1,), np.float32)
91
+ dist_and_mask[...,:-1] = dist
92
+ dist_and_mask[...,-1:] = dist_mask
93
+
94
+
95
+ if self.n_classes is None:
96
+ return [X], [prob,dist_and_mask]
97
+ else:
98
+ prob_class = np.stack(tuple((mask_to_categorical(y, self.n_classes, self.classes[k]) for y,k in zip(Y, idx))))
99
+
100
+ # TODO: investigate downsampling via simple indexing vs. using 'zoom'
101
+ # prob_class = prob_class[self.ss_grid]
102
+ # 'zoom' might lead to better registered maps (especially if upscaled later)
103
+ prob_class = zoom(prob_class, (1,)+tuple(1/g for g in self.grid)+(1,), order=0)
104
+
105
+ return [X], [prob,dist_and_mask, prob_class]
106
+
107
+
108
+
109
+ class Config2D(BaseConfig):
110
+ """Configuration for a :class:`StarDist2D` model.
111
+
112
+ Parameters
113
+ ----------
114
+ axes : str or None
115
+ Axes of the input images.
116
+ n_rays : int
117
+ Number of radial directions for the star-convex polygon.
118
+ Recommended to use a power of 2 (default: 32).
119
+ n_channel_in : int
120
+ Number of channels of given input image (default: 1).
121
+ grid : (int,int)
122
+ Subsampling factors (must be powers of 2) for each of the axes.
123
+ Model will predict on a subsampled grid for increased efficiency and larger field of view.
124
+ n_classes : None or int
125
+ Number of object classes to use for multi-class predection (use None to disable)
126
+ backbone : str
127
+ Name of the neural network architecture to be used as backbone.
128
+ kwargs : dict
129
+ Overwrite (or add) configuration attributes (see below).
130
+
131
+
132
+ Attributes
133
+ ----------
134
+ unet_n_depth : int
135
+ Number of U-Net resolution levels (down/up-sampling layers).
136
+ unet_kernel_size : (int,int)
137
+ Convolution kernel size for all (U-Net) convolution layers.
138
+ unet_n_filter_base : int
139
+ Number of convolution kernels (feature channels) for first U-Net layer.
140
+ Doubled after each down-sampling layer.
141
+ unet_pool : (int,int)
142
+ Maxpooling size for all (U-Net) convolution layers.
143
+ net_conv_after_unet : int
144
+ Number of filters of the extra convolution layer after U-Net (0 to disable).
145
+ unet_* : *
146
+ Additional parameters for U-net backbone.
147
+ train_shape_completion : bool
148
+ Train model to predict complete shapes for partially visible objects at image boundary.
149
+ train_completion_crop : int
150
+ If 'train_shape_completion' is set to True, specify number of pixels to crop at boundary of training patches.
151
+ Should be chosen based on (largest) object sizes.
152
+ train_patch_size : (int,int)
153
+ Size of patches to be cropped from provided training images.
154
+ train_background_reg : float
155
+ Regularizer to encourage distance predictions on background regions to be 0.
156
+ train_foreground_only : float
157
+ Fraction (0..1) of patches that will only be sampled from regions that contain foreground pixels.
158
+ train_sample_cache : bool
159
+ Activate caching of valid patch regions for all training images (disable to save memory for large datasets)
160
+ train_dist_loss : str
161
+ Training loss for star-convex polygon distances ('mse' or 'mae').
162
+ train_loss_weights : tuple of float
163
+ Weights for losses relating to (probability, distance)
164
+ train_epochs : int
165
+ Number of training epochs.
166
+ train_steps_per_epoch : int
167
+ Number of parameter update steps per epoch.
168
+ train_learning_rate : float
169
+ Learning rate for training.
170
+ train_batch_size : int
171
+ Batch size for training.
172
+ train_n_val_patches : int
173
+ Number of patches to be extracted from validation images (``None`` = one patch per image).
174
+ train_tensorboard : bool
175
+ Enable TensorBoard for monitoring training progress.
176
+ train_reduce_lr : dict
177
+ Parameter :class:`dict` of ReduceLROnPlateau_ callback; set to ``None`` to disable.
178
+ use_gpu : bool
179
+ Indicate that the data generator should use OpenCL to do computations on the GPU.
180
+
181
+ .. _ReduceLROnPlateau: https://keras.io/api/callbacks/reduce_lr_on_plateau/
182
+ """
183
+
184
+ def __init__(self, axes='YX', n_rays=32, n_channel_in=1, grid=(1,1), n_classes=None, backbone='unet', **kwargs):
185
+ """See class docstring."""
186
+
187
+ super().__init__(axes=axes, n_channel_in=n_channel_in, n_channel_out=1+n_rays)
188
+
189
+ # directly set by parameters
190
+ self.n_rays = int(n_rays)
191
+ self.grid = _normalize_grid(grid,2)
192
+ self.backbone = str(backbone).lower()
193
+ self.n_classes = None if n_classes is None else int(n_classes)
194
+
195
+ # default config (can be overwritten by kwargs below)
196
+ if self.backbone == 'unet':
197
+ self.unet_n_depth = 3
198
+ self.unet_kernel_size = 3,3
199
+ self.unet_n_filter_base = 32
200
+ self.unet_n_conv_per_depth = 2
201
+ self.unet_pool = 2,2
202
+ self.unet_activation = 'relu'
203
+ self.unet_last_activation = 'relu'
204
+ self.unet_batch_norm = False
205
+ self.unet_dropout = 0.0
206
+ self.unet_prefix = ''
207
+ self.net_conv_after_unet = 128
208
+ else:
209
+ # TODO: resnet backbone for 2D model?
210
+ raise ValueError("backbone '%s' not supported." % self.backbone)
211
+
212
+ # net_mask_shape not needed but kept for legacy reasons
213
+ if backend_channels_last():
214
+ self.net_input_shape = None,None,self.n_channel_in
215
+ self.net_mask_shape = None,None,1
216
+ else:
217
+ self.net_input_shape = self.n_channel_in,None,None
218
+ self.net_mask_shape = 1,None,None
219
+
220
+ self.train_shape_completion = False
221
+ self.train_completion_crop = 32
222
+ self.train_patch_size = 256,256
223
+ self.train_background_reg = 1e-4
224
+ self.train_foreground_only = 0.9
225
+ self.train_sample_cache = True
226
+
227
+ self.train_dist_loss = 'mae'
228
+ self.train_loss_weights = (1,0.2) if self.n_classes is None else (1,0.2,1)
229
+ self.train_class_weights = (1,1) if self.n_classes is None else (1,)*(self.n_classes+1)
230
+ self.train_epochs = 400
231
+ self.train_steps_per_epoch = 100
232
+ self.train_learning_rate = 0.0003
233
+ self.train_batch_size = 4
234
+ self.train_n_val_patches = None
235
+ self.train_tensorboard = True
236
+ # the parameter 'min_delta' was called 'epsilon' for keras<=2.1.5
237
+ min_delta_key = 'epsilon' if LooseVersion(keras.__version__)<=LooseVersion('2.1.5') else 'min_delta'
238
+ self.train_reduce_lr = {'factor': 0.5, 'patience': 40, min_delta_key: 0}
239
+
240
+ self.use_gpu = False
241
+
242
+ # remove derived attributes that shouldn't be overwritten
243
+ for k in ('n_dim', 'n_channel_out'):
244
+ try: del kwargs[k]
245
+ except KeyError: pass
246
+
247
+ self.update_parameters(False, **kwargs)
248
+
249
+ # FIXME: put into is_valid()
250
+ if not len(self.train_loss_weights) == (2 if self.n_classes is None else 3):
251
+ raise ValueError(f"train_loss_weights {self.train_loss_weights} not compatible with n_classes ({self.n_classes}): must be 3 weights if n_classes is not None, otherwise 2")
252
+
253
+ if not len(self.train_class_weights) == (2 if self.n_classes is None else self.n_classes+1):
254
+ raise ValueError(f"train_class_weights {self.train_class_weights} not compatible with n_classes ({self.n_classes}): must be 'n_classes + 1' weights if n_classes is not None, otherwise 2")
255
+
256
+
257
+
258
+ class StarDist2D(StarDistBase):
259
+ """StarDist2D model.
260
+
261
+ Parameters
262
+ ----------
263
+ config : :class:`Config` or None
264
+ Will be saved to disk as JSON (``config.json``).
265
+ If set to ``None``, will be loaded from disk (must exist).
266
+ name : str or None
267
+ Model name. Uses a timestamp if set to ``None`` (default).
268
+ basedir : str
269
+ Directory that contains (or will contain) a folder with the given model name.
270
+
271
+ Raises
272
+ ------
273
+ FileNotFoundError
274
+ If ``config=None`` and config cannot be loaded from disk.
275
+ ValueError
276
+ Illegal arguments, including invalid configuration.
277
+
278
+ Attributes
279
+ ----------
280
+ config : :class:`Config`
281
+ Configuration, as provided during instantiation.
282
+ keras_model : `Keras model <https://keras.io/getting-started/functional-api-guide/>`_
283
+ Keras neural network model.
284
+ name : str
285
+ Model name.
286
+ logdir : :class:`pathlib.Path`
287
+ Path to model folder (which stores configuration, weights, etc.)
288
+ """
289
+
290
+ def __init__(self, config=Config2D(), name=None, basedir='.'):
291
+ """See class docstring."""
292
+ super().__init__(config, name=name, basedir=basedir)
293
+
294
+
295
+ def _build(self):
296
+ self.config.backbone == 'unet' or _raise(NotImplementedError())
297
+ unet_kwargs = {k[len('unet_'):]:v for (k,v) in vars(self.config).items() if k.startswith('unet_')}
298
+
299
+ input_img = Input(self.config.net_input_shape, name='input')
300
+
301
+ # maxpool input image to grid size
302
+ pooled = np.array([1,1])
303
+ pooled_img = input_img
304
+ while tuple(pooled) != tuple(self.config.grid):
305
+ pool = 1 + (np.asarray(self.config.grid) > pooled)
306
+ pooled *= pool
307
+ for _ in range(self.config.unet_n_conv_per_depth):
308
+ pooled_img = Conv2D(self.config.unet_n_filter_base, self.config.unet_kernel_size,
309
+ padding='same', activation=self.config.unet_activation)(pooled_img)
310
+ pooled_img = MaxPooling2D(pool)(pooled_img)
311
+
312
+ unet_base = unet_block(**unet_kwargs)(pooled_img)
313
+
314
+ if self.config.net_conv_after_unet > 0:
315
+ unet = Conv2D(self.config.net_conv_after_unet, self.config.unet_kernel_size,
316
+ name='features', padding='same', activation=self.config.unet_activation)(unet_base)
317
+ else:
318
+ unet = unet_base
319
+
320
+ output_prob = Conv2D( 1, (1,1), name='prob', padding='same', activation='sigmoid')(unet)
321
+ output_dist = Conv2D(self.config.n_rays, (1,1), name='dist', padding='same', activation='linear')(unet)
322
+
323
+ # attach extra classification head when self.n_classes is given
324
+ if self._is_multiclass():
325
+ if self.config.net_conv_after_unet > 0:
326
+ unet_class = Conv2D(self.config.net_conv_after_unet, self.config.unet_kernel_size,
327
+ name='features_class', padding='same', activation=self.config.unet_activation)(unet_base)
328
+ else:
329
+ unet_class = unet_base
330
+
331
+ output_prob_class = Conv2D(self.config.n_classes+1, (1,1), name='prob_class', padding='same', activation='softmax')(unet_class)
332
+ return Model([input_img], [output_prob,output_dist,output_prob_class])
333
+ else:
334
+ return Model([input_img], [output_prob,output_dist])
335
+
336
+
337
+ def train(self, X, Y, validation_data, classes='auto', augmenter=None, seed=None, epochs=None, steps_per_epoch=None, workers=1):
338
+ """Train the neural network with the given data.
339
+
340
+ Parameters
341
+ ----------
342
+ X : tuple, list, `numpy.ndarray`, `keras.utils.Sequence`
343
+ Input images
344
+ Y : tuple, list, `numpy.ndarray`, `keras.utils.Sequence`
345
+ Label masks
346
+ classes (optional): 'auto' or iterable of same length as X
347
+ label id -> class id mapping for each label mask of Y if multiclass prediction is activated (n_classes > 0)
348
+ list of dicts with label id -> class id (1,...,n_classes)
349
+ 'auto' -> all objects will be assigned to the first non-background class,
350
+ or will be ignored if config.n_classes is None
351
+ validation_data : tuple(:class:`numpy.ndarray`, :class:`numpy.ndarray`) or triple (if multiclass)
352
+ Tuple (triple if multiclass) of X,Y,[classes] validation data.
353
+ augmenter : None or callable
354
+ Function with expected signature ``xt, yt = augmenter(x, y)``
355
+ that takes in a single pair of input/label image (x,y) and returns
356
+ the transformed images (xt, yt) for the purpose of data augmentation
357
+ during training. Not applied to validation images.
358
+ Example:
359
+ def simple_augmenter(x,y):
360
+ x = x + 0.05*np.random.normal(0,1,x.shape)
361
+ return x,y
362
+ seed : int
363
+ Convenience to set ``np.random.seed(seed)``. (To obtain reproducible validation patches, etc.)
364
+ epochs : int
365
+ Optional argument to use instead of the value from ``config``.
366
+ steps_per_epoch : int
367
+ Optional argument to use instead of the value from ``config``.
368
+
369
+ Returns
370
+ -------
371
+ ``History`` object
372
+ See `Keras training history <https://keras.io/models/model/#fit>`_.
373
+
374
+ """
375
+ if seed is not None:
376
+ # https://keras.io/getting-started/faq/#how-can-i-obtain-reproducible-results-using-keras-during-development
377
+ np.random.seed(seed)
378
+ if epochs is None:
379
+ epochs = self.config.train_epochs
380
+ if steps_per_epoch is None:
381
+ steps_per_epoch = self.config.train_steps_per_epoch
382
+
383
+ classes = self._parse_classes_arg(classes, len(X))
384
+
385
+ if not self._is_multiclass() and classes is not None:
386
+ warnings.warn("Ignoring given classes as n_classes is set to None")
387
+
388
+ isinstance(validation_data,(list,tuple)) or _raise(ValueError())
389
+ if self._is_multiclass() and len(validation_data) == 2:
390
+ validation_data = tuple(validation_data) + ('auto',)
391
+ ((len(validation_data) == (3 if self._is_multiclass() else 2))
392
+ or _raise(ValueError(f'len(validation_data) = {len(validation_data)}, but should be {3 if self._is_multiclass() else 2}')))
393
+
394
+ patch_size = self.config.train_patch_size
395
+ axes = self.config.axes.replace('C','')
396
+ b = self.config.train_completion_crop if self.config.train_shape_completion else 0
397
+ div_by = self._axes_div_by(axes)
398
+ [(p-2*b) % d == 0 or _raise(ValueError(
399
+ "'train_patch_size' - 2*'train_completion_crop' must be divisible by {d} along axis '{a}'".format(a=a,d=d) if self.config.train_shape_completion else
400
+ "'train_patch_size' must be divisible by {d} along axis '{a}'".format(a=a,d=d)
401
+ )) for p,d,a in zip(patch_size,div_by,axes)]
402
+
403
+ if not self._model_prepared:
404
+ self.prepare_for_training()
405
+
406
+ data_kwargs = dict (
407
+ n_rays = self.config.n_rays,
408
+ patch_size = self.config.train_patch_size,
409
+ grid = self.config.grid,
410
+ shape_completion = self.config.train_shape_completion,
411
+ b = self.config.train_completion_crop,
412
+ use_gpu = self.config.use_gpu,
413
+ foreground_prob = self.config.train_foreground_only,
414
+ n_classes = self.config.n_classes,
415
+ sample_ind_cache = self.config.train_sample_cache,
416
+ )
417
+
418
+ # generate validation data and store in numpy arrays
419
+ n_data_val = len(validation_data[0])
420
+ classes_val = self._parse_classes_arg(validation_data[2], n_data_val) if self._is_multiclass() else None
421
+ n_take = self.config.train_n_val_patches if self.config.train_n_val_patches is not None else n_data_val
422
+ _data_val = StarDistData2D(validation_data[0],validation_data[1], classes=classes_val, batch_size=n_take, length=1, **data_kwargs)
423
+ data_val = _data_val[0]
424
+
425
+ # expose data generator as member for general diagnostics
426
+ self.data_train = StarDistData2D(X, Y, classes=classes, batch_size=self.config.train_batch_size,
427
+ augmenter=augmenter, length=epochs*steps_per_epoch, **data_kwargs)
428
+
429
+ if self.config.train_tensorboard:
430
+ # show dist for three rays
431
+ _n = min(3, self.config.n_rays)
432
+ channel = axes_dict(self.config.axes)['C']
433
+ output_slices = [[slice(None)]*4,[slice(None)]*4]
434
+ output_slices[1][1+channel] = slice(0,(self.config.n_rays//_n)*_n, self.config.n_rays//_n)
435
+ if self._is_multiclass():
436
+ _n = min(3, self.config.n_classes)
437
+ output_slices += [[slice(None)]*4]
438
+ output_slices[2][1+channel] = slice(1,1+(self.config.n_classes//_n)*_n, self.config.n_classes//_n)
439
+
440
+ if IS_TF_1:
441
+ for cb in self.callbacks:
442
+ if isinstance(cb,CARETensorBoard):
443
+ cb.output_slices = output_slices
444
+ # target image for dist includes dist_mask and thus has more channels than dist output
445
+ cb.output_target_shapes = [None,[None]*4,None]
446
+ cb.output_target_shapes[1][1+channel] = data_val[1][1].shape[1+channel]
447
+ elif self.basedir is not None and not any(isinstance(cb,CARETensorBoardImage) for cb in self.callbacks):
448
+ self.callbacks.append(CARETensorBoardImage(model=self.keras_model, data=data_val, log_dir=str(self.logdir/'logs'/'images'),
449
+ n_images=3, prob_out=False, output_slices=output_slices))
450
+
451
+ fit = self.keras_model.fit_generator if IS_TF_1 else self.keras_model.fit
452
+ history = fit(iter(self.data_train), validation_data=data_val,
453
+ epochs=epochs, steps_per_epoch=steps_per_epoch,
454
+ workers=workers, use_multiprocessing=workers>1,
455
+ callbacks=self.callbacks, verbose=1,
456
+ # set validation batchsize to training batchsize (only works for tf >= 2.2)
457
+ **(dict(validation_batch_size = self.config.train_batch_size) if _tf_version_at_least("2.2.0") else {}))
458
+ self._training_finished()
459
+
460
+ return history
461
+
462
+
463
+ # def _instances_from_prediction_old(self, img_shape, prob, dist,points = None, prob_class = None, prob_thresh=None, nms_thresh=None, overlap_label = None, **nms_kwargs):
464
+ # from stardist.geometry.geom2d import _polygons_to_label_old, _dist_to_coord_old
465
+ # from stardist.nms import _non_maximum_suppression_old
466
+
467
+ # if prob_thresh is None: prob_thresh = self.thresholds.prob
468
+ # if nms_thresh is None: nms_thresh = self.thresholds.nms
469
+ # if overlap_label is not None: raise NotImplementedError("overlap_label not supported for 2D yet!")
470
+
471
+ # coord = _dist_to_coord_old(dist, grid=self.config.grid)
472
+ # inds = _non_maximum_suppression_old(coord, prob, grid=self.config.grid,
473
+ # prob_thresh=prob_thresh, nms_thresh=nms_thresh, **nms_kwargs)
474
+ # labels = _polygons_to_label_old(coord, prob, inds, shape=img_shape)
475
+ # # sort 'inds' such that ids in 'labels' map to entries in polygon dictionary entries
476
+ # inds = inds[np.argsort(prob[inds[:,0],inds[:,1]])]
477
+ # # adjust for grid
478
+ # points = inds*np.array(self.config.grid)
479
+
480
+ # res_dict = dict(coord=coord[inds[:,0],inds[:,1]], points=points, prob=prob[inds[:,0],inds[:,1]])
481
+
482
+ # if prob_class is not None:
483
+ # prob_class = np.asarray(prob_class)
484
+ # res_dict.update(dict(class_prob = prob_class))
485
+
486
+ # return labels, res_dict
487
+
488
+
489
+ def _instances_from_prediction(self, img_shape, prob, dist, points=None, prob_class=None, prob_thresh=None, nms_thresh=None, overlap_label=None, return_labels=True, scale=None, **nms_kwargs):
490
+ """
491
+ if points is None -> dense prediction
492
+ if points is not None -> sparse prediction
493
+
494
+ if prob_class is None -> single class prediction
495
+ if prob_class is not None -> multi class prediction
496
+ """
497
+ if prob_thresh is None: prob_thresh = self.thresholds.prob
498
+ if nms_thresh is None: nms_thresh = self.thresholds.nms
499
+ if overlap_label is not None: raise NotImplementedError("overlap_label not supported for 2D yet!")
500
+
501
+ # sparse prediction
502
+ if points is not None:
503
+ points, probi, disti, indsi = non_maximum_suppression_sparse(dist, prob, points, nms_thresh=nms_thresh, **nms_kwargs)
504
+ if prob_class is not None:
505
+ prob_class = prob_class[indsi]
506
+
507
+ # dense prediction
508
+ else:
509
+ points, probi, disti = non_maximum_suppression(dist, prob, grid=self.config.grid,
510
+ prob_thresh=prob_thresh, nms_thresh=nms_thresh, **nms_kwargs)
511
+ if prob_class is not None:
512
+ inds = tuple(p//g for p,g in zip(points.T, self.config.grid))
513
+ prob_class = prob_class[inds]
514
+
515
+ if scale is not None:
516
+ # need to undo the scaling given by the scale dict, e.g. scale = dict(X=0.5,Y=0.5):
517
+ # 1. re-scale points (origins of polygons)
518
+ # 2. re-scale coordinates (computed from distances) of (zero-origin) polygons
519
+ if not (isinstance(scale,dict) and 'X' in scale and 'Y' in scale):
520
+ raise ValueError("scale must be a dictionary with entries for 'X' and 'Y'")
521
+ rescale = (1/scale['Y'],1/scale['X'])
522
+ points = points * np.array(rescale).reshape(1,2)
523
+ else:
524
+ rescale = (1,1)
525
+
526
+ if return_labels:
527
+ labels = polygons_to_label(disti, points, prob=probi, shape=img_shape, scale_dist=rescale)
528
+ else:
529
+ labels = None
530
+
531
+ coord = dist_to_coord(disti, points, scale_dist=rescale)
532
+ res_dict = dict(coord=coord, points=points, prob=probi)
533
+
534
+ # multi class prediction
535
+ if prob_class is not None:
536
+ prob_class = np.asarray(prob_class)
537
+ class_id = np.argmax(prob_class, axis=-1)
538
+ res_dict.update(dict(class_prob=prob_class, class_id=class_id))
539
+
540
+ return labels, res_dict
541
+
542
+
543
+ def _axes_div_by(self, query_axes):
544
+ self.config.backbone == 'unet' or _raise(NotImplementedError())
545
+ query_axes = axes_check_and_normalize(query_axes)
546
+ assert len(self.config.unet_pool) == len(self.config.grid)
547
+ div_by = dict(zip(
548
+ self.config.axes.replace('C',''),
549
+ tuple(p**self.config.unet_n_depth * g for p,g in zip(self.config.unet_pool,self.config.grid))
550
+ ))
551
+ return tuple(div_by.get(a,1) for a in query_axes)
552
+
553
+
554
+ # def _axes_tile_overlap(self, query_axes):
555
+ # self.config.backbone == 'unet' or _raise(NotImplementedError())
556
+ # query_axes = axes_check_and_normalize(query_axes)
557
+ # assert len(self.config.unet_pool) == len(self.config.grid) == len(self.config.unet_kernel_size)
558
+ # # TODO: compute this properly when any value of grid > 1
559
+ # # all(g==1 for g in self.config.grid) or warnings.warn('FIXME')
560
+ # overlap = dict(zip(
561
+ # self.config.axes.replace('C',''),
562
+ # tuple(tile_overlap(self.config.unet_n_depth + int(np.log2(g)), k, p)
563
+ # for p,k,g in zip(self.config.unet_pool,self.config.unet_kernel_size,self.config.grid))
564
+ # ))
565
+ # return tuple(overlap.get(a,0) for a in query_axes)
566
+
567
+
568
+ @property
569
+ def _config_class(self):
570
+ return Config2D
stardist_pkg/nms.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import print_function, unicode_literals, absolute_import, division
2
+ import numpy as np
3
+ from time import time
4
+ from .utils import _normalize_grid
5
+
6
+ def _ind_prob_thresh(prob, prob_thresh, b=2):
7
+ if b is not None and np.isscalar(b):
8
+ b = ((b,b),)*prob.ndim
9
+
10
+ ind_thresh = prob > prob_thresh
11
+ if b is not None:
12
+ _ind_thresh = np.zeros_like(ind_thresh)
13
+ ss = tuple(slice(_bs[0] if _bs[0]>0 else None,
14
+ -_bs[1] if _bs[1]>0 else None) for _bs in b)
15
+ _ind_thresh[ss] = True
16
+ ind_thresh &= _ind_thresh
17
+ return ind_thresh
18
+
19
+
20
+ def _non_maximum_suppression_old(coord, prob, grid=(1,1), b=2, nms_thresh=0.5, prob_thresh=0.5, verbose=False, max_bbox_search=True):
21
+ """2D coordinates of the polys that survive from a given prediction (prob, coord)
22
+
23
+ prob.shape = (Ny,Nx)
24
+ coord.shape = (Ny,Nx,2,n_rays)
25
+
26
+ b: don't use pixel closer than b pixels to the image boundary
27
+
28
+ returns retained points
29
+ """
30
+ from .lib.stardist2d import c_non_max_suppression_inds_old
31
+
32
+ # TODO: using b>0 with grid>1 can suppress small/cropped objects at the image boundary
33
+
34
+ assert prob.ndim == 2
35
+ assert coord.ndim == 4
36
+ grid = _normalize_grid(grid,2)
37
+
38
+ # mask = prob > prob_thresh
39
+ # if b is not None and b > 0:
40
+ # _mask = np.zeros_like(mask)
41
+ # _mask[b:-b,b:-b] = True
42
+ # mask &= _mask
43
+
44
+ mask = _ind_prob_thresh(prob, prob_thresh, b)
45
+
46
+ polygons = coord[mask]
47
+ scores = prob[mask]
48
+
49
+ # sort scores descendingly
50
+ ind = np.argsort(scores)[::-1]
51
+ survivors = np.zeros(len(ind), bool)
52
+ polygons = polygons[ind]
53
+ scores = scores[ind]
54
+
55
+ if max_bbox_search:
56
+ # map pixel indices to ids of sorted polygons (-1 => polygon at that pixel not a candidate)
57
+ mapping = -np.ones(mask.shape,np.int32)
58
+ mapping.flat[ np.flatnonzero(mask)[ind] ] = range(len(ind))
59
+ else:
60
+ mapping = np.empty((0,0),np.int32)
61
+
62
+ if verbose:
63
+ t = time()
64
+
65
+ survivors[ind] = c_non_max_suppression_inds_old(np.ascontiguousarray(polygons.astype(np.int32)),
66
+ mapping, np.float32(nms_thresh), np.int32(max_bbox_search),
67
+ np.int32(grid[0]), np.int32(grid[1]),np.int32(verbose))
68
+
69
+ if verbose:
70
+ print("keeping %s/%s polygons" % (np.count_nonzero(survivors), len(polygons)))
71
+ print("NMS took %.4f s" % (time() - t))
72
+
73
+ points = np.stack([ii[survivors] for ii in np.nonzero(mask)],axis=-1)
74
+ return points
75
+
76
+
77
+ def non_maximum_suppression(dist, prob, grid=(1,1), b=2, nms_thresh=0.5, prob_thresh=0.5,
78
+ use_bbox=True, use_kdtree=True, verbose=False,cut=False):
79
+ """Non-Maximum-Supression of 2D polygons
80
+
81
+ Retains only polygons whose overlap is smaller than nms_thresh
82
+
83
+ dist.shape = (Ny,Nx, n_rays)
84
+ prob.shape = (Ny,Nx)
85
+
86
+ returns the retained points, probabilities, and distances:
87
+
88
+ points, prob, dist = non_maximum_suppression(dist, prob, ....
89
+
90
+ """
91
+
92
+ # TODO: using b>0 with grid>1 can suppress small/cropped objects at the image boundary
93
+
94
+ assert prob.ndim == 2 and dist.ndim == 3 and prob.shape == dist.shape[:2]
95
+ dist = np.asarray(dist)
96
+ prob = np.asarray(prob)
97
+ n_rays = dist.shape[-1]
98
+
99
+ grid = _normalize_grid(grid,2)
100
+
101
+ # mask = prob > prob_thresh
102
+ # if b is not None and b > 0:
103
+ # _mask = np.zeros_like(mask)
104
+ # _mask[b:-b,b:-b] = True
105
+ # mask &= _mask
106
+
107
+ mask = _ind_prob_thresh(prob, prob_thresh, b)
108
+ points = np.stack(np.where(mask), axis=1)
109
+
110
+ dist = dist[mask]
111
+ scores = prob[mask]
112
+
113
+ # sort scores descendingly
114
+ ind = np.argsort(scores)[::-1]
115
+ if cut is True and ind.shape[0] > 20000:
116
+ #if cut is True and :
117
+ ind = ind[:round(ind.shape[0]*0.5)]
118
+ dist = dist[ind]
119
+ scores = scores[ind]
120
+ points = points[ind]
121
+
122
+ points = (points * np.array(grid).reshape((1,2)))
123
+
124
+ if verbose:
125
+ t = time()
126
+
127
+ inds = non_maximum_suppression_inds(dist, points.astype(np.int32, copy=False), scores=scores,
128
+ use_bbox=use_bbox, use_kdtree=use_kdtree,
129
+ thresh=nms_thresh, verbose=verbose)
130
+
131
+ if verbose:
132
+ print("keeping %s/%s polygons" % (np.count_nonzero(inds), len(inds)))
133
+ print("NMS took %.4f s" % (time() - t))
134
+
135
+ return points[inds], scores[inds], dist[inds]
136
+
137
+
138
+ def non_maximum_suppression_sparse(dist, prob, points, b=2, nms_thresh=0.5,
139
+ use_bbox=True, use_kdtree = True, verbose=False):
140
+ """Non-Maximum-Supression of 2D polygons from a list of dists, probs (scores), and points
141
+
142
+ Retains only polyhedra whose overlap is smaller than nms_thresh
143
+
144
+ dist.shape = (n_polys, n_rays)
145
+ prob.shape = (n_polys,)
146
+ points.shape = (n_polys,2)
147
+
148
+ returns the retained instances
149
+
150
+ (pointsi, probi, disti, indsi)
151
+
152
+ with
153
+ pointsi = points[indsi] ...
154
+
155
+ """
156
+
157
+ # TODO: using b>0 with grid>1 can suppress small/cropped objects at the image boundary
158
+
159
+ dist = np.asarray(dist)
160
+ prob = np.asarray(prob)
161
+ points = np.asarray(points)
162
+ n_rays = dist.shape[-1]
163
+
164
+ assert dist.ndim == 2 and prob.ndim == 1 and points.ndim == 2 and \
165
+ points.shape[-1]==2 and len(prob) == len(dist) == len(points)
166
+
167
+ verbose and print("predicting instances with nms_thresh = {nms_thresh}".format(nms_thresh=nms_thresh), flush=True)
168
+
169
+ inds_original = np.arange(len(prob))
170
+ _sorted = np.argsort(prob)[::-1]
171
+ probi = prob[_sorted]
172
+ disti = dist[_sorted]
173
+ pointsi = points[_sorted]
174
+ inds_original = inds_original[_sorted]
175
+
176
+ if verbose:
177
+ print("non-maximum suppression...")
178
+ t = time()
179
+
180
+ inds = non_maximum_suppression_inds(disti, pointsi, scores=probi, thresh=nms_thresh, use_kdtree = use_kdtree, verbose=verbose)
181
+
182
+ if verbose:
183
+ print("keeping %s/%s polyhedra" % (np.count_nonzero(inds), len(inds)))
184
+ print("NMS took %.4f s" % (time() - t))
185
+
186
+ return pointsi[inds], probi[inds], disti[inds], inds_original[inds]
187
+
188
+
189
+ def non_maximum_suppression_inds(dist, points, scores, thresh=0.5, use_bbox=True, use_kdtree = True, verbose=1):
190
+ """
191
+ Applies non maximum supression to ray-convex polygons given by dists and points
192
+ sorted by scores and IoU threshold
193
+
194
+ P1 will suppress P2, if IoU(P1,P2) > thresh
195
+
196
+ with IoU(P1,P2) = Ainter(P1,P2) / min(A(P1),A(P2))
197
+
198
+ i.e. the smaller thresh, the more polygons will be supressed
199
+
200
+ dist.shape = (n_poly, n_rays)
201
+ point.shape = (n_poly, 2)
202
+ score.shape = (n_poly,)
203
+
204
+ returns indices of selected polygons
205
+ """
206
+
207
+ from stardist.lib.stardist2d import c_non_max_suppression_inds
208
+
209
+ assert dist.ndim == 2
210
+ assert points.ndim == 2
211
+
212
+ n_poly = dist.shape[0]
213
+
214
+ if scores is None:
215
+ scores = np.ones(n_poly)
216
+
217
+ assert len(scores) == n_poly
218
+ assert points.shape[0] == n_poly
219
+
220
+ def _prep(x, dtype):
221
+ return np.ascontiguousarray(x.astype(dtype, copy=False))
222
+
223
+ inds = c_non_max_suppression_inds(_prep(dist, np.float32),
224
+ _prep(points, np.float32),
225
+ int(use_kdtree),
226
+ int(use_bbox),
227
+ int(verbose),
228
+ np.float32(thresh))
229
+
230
+ return inds
231
+
232
+
233
+ #########
234
+
235
+
236
+ def non_maximum_suppression_3d(dist, prob, rays, grid=(1,1,1), b=2, nms_thresh=0.5, prob_thresh=0.5, use_bbox=True, use_kdtree=True, verbose=False):
237
+ """Non-Maximum-Supression of 3D polyhedra
238
+
239
+ Retains only polyhedra whose overlap is smaller than nms_thresh
240
+
241
+ dist.shape = (Nz,Ny,Nx, n_rays)
242
+ prob.shape = (Nz,Ny,Nx)
243
+
244
+ returns the retained points, probabilities, and distances:
245
+
246
+ points, prob, dist = non_maximum_suppression_3d(dist, prob, ....
247
+ """
248
+
249
+ # TODO: using b>0 with grid>1 can suppress small/cropped objects at the image boundary
250
+
251
+ dist = np.asarray(dist)
252
+ prob = np.asarray(prob)
253
+
254
+ assert prob.ndim == 3 and dist.ndim == 4 and dist.shape[-1] == len(rays) and prob.shape == dist.shape[:3]
255
+
256
+ grid = _normalize_grid(grid,3)
257
+
258
+ verbose and print("predicting instances with prob_thresh = {prob_thresh} and nms_thresh = {nms_thresh}".format(prob_thresh=prob_thresh, nms_thresh=nms_thresh), flush=True)
259
+
260
+ # ind_thresh = prob > prob_thresh
261
+ # if b is not None and b > 0:
262
+ # _ind_thresh = np.zeros_like(ind_thresh)
263
+ # _ind_thresh[b:-b,b:-b,b:-b] = True
264
+ # ind_thresh &= _ind_thresh
265
+
266
+ ind_thresh = _ind_prob_thresh(prob, prob_thresh, b)
267
+ points = np.stack(np.where(ind_thresh), axis=1)
268
+ verbose and print("found %s candidates"%len(points))
269
+ probi = prob[ind_thresh]
270
+ disti = dist[ind_thresh]
271
+
272
+ _sorted = np.argsort(probi)[::-1]
273
+ probi = probi[_sorted]
274
+ disti = disti[_sorted]
275
+ points = points[_sorted]
276
+
277
+ verbose and print("non-maximum suppression...")
278
+ points = (points * np.array(grid).reshape((1,3)))
279
+
280
+ inds = non_maximum_suppression_3d_inds(disti, points, rays=rays, scores=probi, thresh=nms_thresh,
281
+ use_bbox=use_bbox, use_kdtree = use_kdtree,
282
+ verbose=verbose)
283
+
284
+ verbose and print("keeping %s/%s polyhedra" % (np.count_nonzero(inds), len(inds)))
285
+ return points[inds], probi[inds], disti[inds]
286
+
287
+
288
+ def non_maximum_suppression_3d_sparse(dist, prob, points, rays, b=2, nms_thresh=0.5, use_kdtree = True, verbose=False):
289
+ """Non-Maximum-Supression of 3D polyhedra from a list of dists, probs and points
290
+
291
+ Retains only polyhedra whose overlap is smaller than nms_thresh
292
+ dist.shape = (n_polys, n_rays)
293
+ prob.shape = (n_polys,)
294
+ points.shape = (n_polys,3)
295
+
296
+ returns the retained instances
297
+
298
+ (pointsi, probi, disti, indsi)
299
+
300
+ with
301
+ pointsi = points[indsi] ...
302
+ """
303
+
304
+ # TODO: using b>0 with grid>1 can suppress small/cropped objects at the image boundary
305
+
306
+ dist = np.asarray(dist)
307
+ prob = np.asarray(prob)
308
+ points = np.asarray(points)
309
+
310
+ assert dist.ndim == 2 and prob.ndim == 1 and points.ndim == 2 and \
311
+ dist.shape[-1] == len(rays) and points.shape[-1]==3 and len(prob) == len(dist) == len(points)
312
+
313
+ verbose and print("predicting instances with nms_thresh = {nms_thresh}".format(nms_thresh=nms_thresh), flush=True)
314
+
315
+ inds_original = np.arange(len(prob))
316
+ _sorted = np.argsort(prob)[::-1]
317
+ probi = prob[_sorted]
318
+ disti = dist[_sorted]
319
+ pointsi = points[_sorted]
320
+ inds_original = inds_original[_sorted]
321
+
322
+ verbose and print("non-maximum suppression...")
323
+
324
+ inds = non_maximum_suppression_3d_inds(disti, pointsi, rays=rays, scores=probi, thresh=nms_thresh, use_kdtree = use_kdtree, verbose=verbose)
325
+
326
+ verbose and print("keeping %s/%s polyhedra" % (np.count_nonzero(inds), len(inds)))
327
+ return pointsi[inds], probi[inds], disti[inds], inds_original[inds]
328
+
329
+
330
+ def non_maximum_suppression_3d_inds(dist, points, rays, scores, thresh=0.5, use_bbox=True, use_kdtree = True, verbose=1):
331
+ """
332
+ Applies non maximum supression to ray-convex polyhedra given by dists and rays
333
+ sorted by scores and IoU threshold
334
+
335
+ P1 will suppress P2, if IoU(P1,P2) > thresh
336
+
337
+ with IoU(P1,P2) = Ainter(P1,P2) / min(A(P1),A(P2))
338
+
339
+ i.e. the smaller thresh, the more polygons will be supressed
340
+
341
+ dist.shape = (n_poly, n_rays)
342
+ point.shape = (n_poly, 3)
343
+ score.shape = (n_poly,)
344
+
345
+ returns indices of selected polygons
346
+ """
347
+ from .lib.stardist3d import c_non_max_suppression_inds
348
+
349
+ assert dist.ndim == 2
350
+ assert points.ndim == 2
351
+ assert dist.shape[1] == len(rays)
352
+
353
+ n_poly = dist.shape[0]
354
+
355
+ if scores is None:
356
+ scores = np.ones(n_poly)
357
+
358
+ assert len(scores) == n_poly
359
+ assert points.shape[0] == n_poly
360
+
361
+ # sort scores descendingly
362
+ ind = np.argsort(scores)[::-1]
363
+ survivors = np.ones(n_poly, bool)
364
+ dist = dist[ind]
365
+ points = points[ind]
366
+ scores = scores[ind]
367
+
368
+ def _prep(x, dtype):
369
+ return np.ascontiguousarray(x.astype(dtype, copy=False))
370
+
371
+ if verbose:
372
+ t = time()
373
+
374
+ survivors[ind] = c_non_max_suppression_inds(_prep(dist, np.float32),
375
+ _prep(points, np.float32),
376
+ _prep(rays.vertices, np.float32),
377
+ _prep(rays.faces, np.int32),
378
+ _prep(scores, np.float32),
379
+ int(use_bbox),
380
+ int(use_kdtree),
381
+ int(verbose),
382
+ np.float32(thresh))
383
+
384
+ if verbose:
385
+ print("NMS took %.4f s" % (time() - t))
386
+
387
+ return survivors
stardist_pkg/rays3d.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ray factory
3
+
4
+ classes that provide vertex and triangle information for rays on spheres
5
+
6
+ Example:
7
+
8
+ rays = Rays_Tetra(n_level = 4)
9
+
10
+ print(rays.vertices)
11
+ print(rays.faces)
12
+
13
+ """
14
+ from __future__ import print_function, unicode_literals, absolute_import, division
15
+ import numpy as np
16
+ from scipy.spatial import ConvexHull
17
+ import copy
18
+ import warnings
19
+
20
+ class Rays_Base(object):
21
+ def __init__(self, **kwargs):
22
+ self.kwargs = kwargs
23
+ self._vertices, self._faces = self.setup_vertices_faces()
24
+ self._vertices = np.asarray(self._vertices, np.float32)
25
+ self._faces = np.asarray(self._faces, int)
26
+ self._faces = np.asanyarray(self._faces)
27
+
28
+ def setup_vertices_faces(self):
29
+ """has to return
30
+
31
+ verts , faces
32
+
33
+ verts = ( (z_1,y_1,x_1), ... )
34
+ faces ( (0,1,2), (2,3,4), ... )
35
+
36
+ """
37
+ raise NotImplementedError()
38
+
39
+ @property
40
+ def vertices(self):
41
+ """read-only property"""
42
+ return self._vertices.copy()
43
+
44
+ @property
45
+ def faces(self):
46
+ """read-only property"""
47
+ return self._faces.copy()
48
+
49
+ def __getitem__(self, i):
50
+ return self.vertices[i]
51
+
52
+ def __len__(self):
53
+ return len(self._vertices)
54
+
55
+ def __repr__(self):
56
+ def _conv(x):
57
+ if isinstance(x,(tuple, list, np.ndarray)):
58
+ return "_".join(_conv(_x) for _x in x)
59
+ if isinstance(x,float):
60
+ return "%.2f"%x
61
+ return str(x)
62
+ return "%s_%s" % (self.__class__.__name__, "_".join("%s_%s" % (k, _conv(v)) for k, v in sorted(self.kwargs.items())))
63
+
64
+ def to_json(self):
65
+ return {
66
+ "name": self.__class__.__name__,
67
+ "kwargs": self.kwargs
68
+ }
69
+
70
+ def dist_loss_weights(self, anisotropy = (1,1,1)):
71
+ """returns the anisotropy corrected weights for each ray"""
72
+ anisotropy = np.array(anisotropy)
73
+ assert anisotropy.shape == (3,)
74
+ return np.linalg.norm(self.vertices*anisotropy, axis = -1)
75
+
76
+ def volume(self, dist=None):
77
+ """volume of the starconvex polyhedron spanned by dist (if None, uses dist=1)
78
+ dist can be a nD array, but the last dimension has to be of length n_rays
79
+ """
80
+ if dist is None: dist = np.ones_like(self.vertices)
81
+
82
+ dist = np.asarray(dist)
83
+
84
+ if not dist.shape[-1]==len(self.vertices):
85
+ raise ValueError("last dimension of dist should have length len(rays.vertices)")
86
+ # all the shuffling below is to allow dist to be an arbitrary sized array (with last dim n_rays)
87
+ # self.vertices -> (n_rays,3)
88
+ # dist -> (m,n,..., n_rays)
89
+
90
+ # dist -> (m,n,..., n_rays, 3)
91
+ dist = np.repeat(np.expand_dims(dist,-1), 3, axis = -1)
92
+ # verts -> (m,n,..., n_rays, 3)
93
+ verts = np.broadcast_to(self.vertices, dist.shape)
94
+
95
+ # dist, verts -> (n_rays, m,n, ..., 3)
96
+ dist = np.moveaxis(dist,-2,0)
97
+ verts = np.moveaxis(verts,-2,0)
98
+
99
+ # vs -> (n_faces, 3, m, n, ..., 3)
100
+ vs = (dist*verts)[self.faces]
101
+ # vs -> (n_faces, m, n, ..., 3, 3)
102
+ vs = np.moveaxis(vs, 1,-2)
103
+ # vs -> (n_faces * m * n, 3, 3)
104
+ vs = vs.reshape((len(self.faces)*int(np.prod(dist.shape[1:-1])),3,3))
105
+ d = np.linalg.det(list(vs)).reshape((len(self.faces),)+dist.shape[1:-1])
106
+
107
+ return -1./6*np.sum(d, axis = 0)
108
+
109
+ def surface(self, dist=None):
110
+ """surface area of the starconvex polyhedron spanned by dist (if None, uses dist=1)"""
111
+ dist = np.asarray(dist)
112
+
113
+ if not dist.shape[-1]==len(self.vertices):
114
+ raise ValueError("last dimension of dist should have length len(rays.vertices)")
115
+
116
+ # self.vertices -> (n_rays,3)
117
+ # dist -> (m,n,..., n_rays)
118
+
119
+ # all the shuffling below is to allow dist to be an arbitrary sized array (with last dim n_rays)
120
+
121
+ # dist -> (m,n,..., n_rays, 3)
122
+ dist = np.repeat(np.expand_dims(dist,-1), 3, axis = -1)
123
+ # verts -> (m,n,..., n_rays, 3)
124
+ verts = np.broadcast_to(self.vertices, dist.shape)
125
+
126
+ # dist, verts -> (n_rays, m,n, ..., 3)
127
+ dist = np.moveaxis(dist,-2,0)
128
+ verts = np.moveaxis(verts,-2,0)
129
+
130
+ # vs -> (n_faces, 3, m, n, ..., 3)
131
+ vs = (dist*verts)[self.faces]
132
+ # vs -> (n_faces, m, n, ..., 3, 3)
133
+ vs = np.moveaxis(vs, 1,-2)
134
+ # vs -> (n_faces * m * n, 3, 3)
135
+ vs = vs.reshape((len(self.faces)*int(np.prod(dist.shape[1:-1])),3,3))
136
+
137
+ pa = vs[...,1,:]-vs[...,0,:]
138
+ pb = vs[...,2,:]-vs[...,0,:]
139
+
140
+ d = .5*np.linalg.norm(np.cross(list(pa), list(pb)), axis = -1)
141
+ d = d.reshape((len(self.faces),)+dist.shape[1:-1])
142
+ return np.sum(d, axis = 0)
143
+
144
+
145
+ def copy(self, scale=(1,1,1)):
146
+ """ returns a copy whose vertices are scaled by given factor"""
147
+ scale = np.asarray(scale)
148
+ assert scale.shape == (3,)
149
+ res = copy.deepcopy(self)
150
+ res._vertices *= scale[np.newaxis]
151
+ return res
152
+
153
+
154
+
155
+
156
+ def rays_from_json(d):
157
+ return eval(d["name"])(**d["kwargs"])
158
+
159
+
160
+ ################################################################
161
+
162
+ class Rays_Explicit(Rays_Base):
163
+ def __init__(self, vertices0, faces0):
164
+ self.vertices0, self.faces0 = vertices0, faces0
165
+ super().__init__(vertices0=list(vertices0), faces0=list(faces0))
166
+
167
+ def setup_vertices_faces(self):
168
+ return self.vertices0, self.faces0
169
+
170
+
171
+ class Rays_Cartesian(Rays_Base):
172
+ def __init__(self, n_rays_x=11, n_rays_z=5):
173
+ super().__init__(n_rays_x=n_rays_x, n_rays_z=n_rays_z)
174
+
175
+ def setup_vertices_faces(self):
176
+ """has to return list of ( (z_1,y_1,x_1), ... ) _"""
177
+ n_rays_x, n_rays_z = self.kwargs["n_rays_x"], self.kwargs["n_rays_z"]
178
+ dphi = np.float32(2. * np.pi / n_rays_x)
179
+ dtheta = np.float32(np.pi / n_rays_z)
180
+
181
+ verts = []
182
+ for mz in range(n_rays_z):
183
+ for mx in range(n_rays_x):
184
+ phi = mx * dphi
185
+ theta = mz * dtheta
186
+ if mz == 0:
187
+ theta = 1e-12
188
+ if mz == n_rays_z - 1:
189
+ theta = np.pi - 1e-12
190
+ dx = np.cos(phi) * np.sin(theta)
191
+ dy = np.sin(phi) * np.sin(theta)
192
+ dz = np.cos(theta)
193
+ if mz == 0 or mz == n_rays_z - 1:
194
+ dx += 1e-12
195
+ dy += 1e-12
196
+ verts.append([dz, dy, dx])
197
+
198
+ verts = np.array(verts)
199
+
200
+ def _ind(mz, mx):
201
+ return mz * n_rays_x + mx
202
+
203
+ faces = []
204
+
205
+ for mz in range(n_rays_z - 1):
206
+ for mx in range(n_rays_x):
207
+ faces.append([_ind(mz, mx), _ind(mz + 1, (mx + 1) % n_rays_x), _ind(mz, (mx + 1) % n_rays_x)])
208
+ faces.append([_ind(mz, mx), _ind(mz + 1, mx), _ind(mz + 1, (mx + 1) % n_rays_x)])
209
+
210
+ faces = np.array(faces)
211
+
212
+ return verts, faces
213
+
214
+
215
+ class Rays_SubDivide(Rays_Base):
216
+ """
217
+ Subdivision polyehdra
218
+
219
+ n_level = 1 -> base polyhedra
220
+ n_level = 2 -> 1x subdivision
221
+ n_level = 3 -> 2x subdivision
222
+ ...
223
+ """
224
+
225
+ def __init__(self, n_level=4):
226
+ super().__init__(n_level=n_level)
227
+
228
+ def base_polyhedron(self):
229
+ raise NotImplementedError()
230
+
231
+ def setup_vertices_faces(self):
232
+ n_level = self.kwargs["n_level"]
233
+ verts0, faces0 = self.base_polyhedron()
234
+ return self._recursive_split(verts0, faces0, n_level)
235
+
236
+ def _recursive_split(self, verts, faces, n_level):
237
+ if n_level <= 1:
238
+ return verts, faces
239
+ else:
240
+ verts, faces = Rays_SubDivide.split(verts, faces)
241
+ return self._recursive_split(verts, faces, n_level - 1)
242
+
243
+ @classmethod
244
+ def split(self, verts0, faces0):
245
+ """split a level"""
246
+
247
+ split_edges = dict()
248
+ verts = list(verts0[:])
249
+ faces = []
250
+
251
+ def _add(a, b):
252
+ """ returns index of middle point and adds vertex if not already added"""
253
+ edge = tuple(sorted((a, b)))
254
+ if not edge in split_edges:
255
+ v = .5 * (verts[a] + verts[b])
256
+ v *= 1. / np.linalg.norm(v)
257
+ verts.append(v)
258
+ split_edges[edge] = len(verts) - 1
259
+ return split_edges[edge]
260
+
261
+ for v1, v2, v3 in faces0:
262
+ ind1 = _add(v1, v2)
263
+ ind2 = _add(v2, v3)
264
+ ind3 = _add(v3, v1)
265
+ faces.append([v1, ind1, ind3])
266
+ faces.append([v2, ind2, ind1])
267
+ faces.append([v3, ind3, ind2])
268
+ faces.append([ind1, ind2, ind3])
269
+
270
+ return verts, faces
271
+
272
+
273
+ class Rays_Tetra(Rays_SubDivide):
274
+ """
275
+ Subdivision of a tetrahedron
276
+
277
+ n_level = 1 -> normal tetrahedron (4 vertices)
278
+ n_level = 2 -> 1x subdivision (10 vertices)
279
+ n_level = 3 -> 2x subdivision (34 vertices)
280
+ ...
281
+ """
282
+
283
+ def base_polyhedron(self):
284
+ verts = np.array([
285
+ [np.sqrt(8. / 9), 0., -1. / 3],
286
+ [-np.sqrt(2. / 9), np.sqrt(2. / 3), -1. / 3],
287
+ [-np.sqrt(2. / 9), -np.sqrt(2. / 3), -1. / 3],
288
+ [0., 0., 1.]
289
+ ])
290
+ faces = [[0, 1, 2],
291
+ [0, 3, 1],
292
+ [0, 2, 3],
293
+ [1, 3, 2]]
294
+
295
+ return verts, faces
296
+
297
+
298
+ class Rays_Octo(Rays_SubDivide):
299
+ """
300
+ Subdivision of a tetrahedron
301
+
302
+ n_level = 1 -> normal Octahedron (6 vertices)
303
+ n_level = 2 -> 1x subdivision (18 vertices)
304
+ n_level = 3 -> 2x subdivision (66 vertices)
305
+
306
+ """
307
+
308
+ def base_polyhedron(self):
309
+ verts = np.array([
310
+ [0, 0, 1],
311
+ [0, 1, 0],
312
+ [0, 0, -1],
313
+ [0, -1, 0],
314
+ [1, 0, 0],
315
+ [-1, 0, 0]])
316
+
317
+ faces = [[0, 1, 4],
318
+ [0, 5, 1],
319
+ [1, 2, 4],
320
+ [1, 5, 2],
321
+ [2, 3, 4],
322
+ [2, 5, 3],
323
+ [3, 0, 4],
324
+ [3, 5, 0],
325
+ ]
326
+
327
+ return verts, faces
328
+
329
+
330
+ def reorder_faces(verts, faces):
331
+ """reorder faces such that their orientation points outward"""
332
+ def _single(face):
333
+ return face[::-1] if np.linalg.det(verts[face])>0 else face
334
+ return tuple(map(_single, faces))
335
+
336
+
337
+ class Rays_GoldenSpiral(Rays_Base):
338
+ def __init__(self, n=70, anisotropy = None):
339
+ if n<4:
340
+ raise ValueError("At least 4 points have to be given!")
341
+ super().__init__(n=n, anisotropy = anisotropy if anisotropy is None else tuple(anisotropy))
342
+
343
+ def setup_vertices_faces(self):
344
+ n = self.kwargs["n"]
345
+ anisotropy = self.kwargs["anisotropy"]
346
+ if anisotropy is None:
347
+ anisotropy = np.ones(3)
348
+ else:
349
+ anisotropy = np.array(anisotropy)
350
+
351
+ # the smaller golden angle = 2pi * 0.3819...
352
+ g = (3. - np.sqrt(5.)) * np.pi
353
+ phi = g * np.arange(n)
354
+ # z = np.linspace(-1, 1, n + 2)[1:-1]
355
+ # rho = np.sqrt(1. - z ** 2)
356
+ # verts = np.stack([rho*np.cos(phi), rho*np.sin(phi),z]).T
357
+ #
358
+ z = np.linspace(-1, 1, n)
359
+ rho = np.sqrt(1. - z ** 2)
360
+ verts = np.stack([z, rho * np.sin(phi), rho * np.cos(phi)]).T
361
+
362
+ # warnings.warn("ray definition has changed! Old results are invalid!")
363
+
364
+ # correct for anisotropy
365
+ verts = verts/anisotropy
366
+ #verts /= np.linalg.norm(verts, axis=-1, keepdims=True)
367
+
368
+ hull = ConvexHull(verts)
369
+ faces = reorder_faces(verts,hull.simplices)
370
+
371
+ verts /= np.linalg.norm(verts, axis=-1, keepdims=True)
372
+
373
+ return verts, faces
stardist_pkg/sample_patches.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """provides a faster sampling function"""
2
+
3
+ import numpy as np
4
+ from csbdeep.utils import _raise, choice
5
+
6
+
7
+ def sample_patches(datas, patch_size, n_samples, valid_inds=None, verbose=False):
8
+ """optimized version of csbdeep.data.sample_patches_from_multiple_stacks
9
+ """
10
+
11
+ len(patch_size)==datas[0].ndim or _raise(ValueError())
12
+
13
+ if not all(( a.shape == datas[0].shape for a in datas )):
14
+ raise ValueError("all input shapes must be the same: %s" % (" / ".join(str(a.shape) for a in datas)))
15
+
16
+ if not all(( 0 < s <= d for s,d in zip(patch_size,datas[0].shape) )):
17
+ raise ValueError("patch_size %s negative or larger than data shape %s along some dimensions" % (str(patch_size), str(datas[0].shape)))
18
+
19
+ if valid_inds is None:
20
+ valid_inds = tuple(_s.ravel() for _s in np.meshgrid(*tuple(np.arange(p//2,s-p//2+1) for s,p in zip(datas[0].shape, patch_size))))
21
+
22
+ n_valid = len(valid_inds[0])
23
+
24
+ if n_valid == 0:
25
+ raise ValueError("no regions to sample from!")
26
+
27
+ idx = choice(range(n_valid), n_samples, replace=(n_valid < n_samples))
28
+ rand_inds = [v[idx] for v in valid_inds]
29
+ res = [np.stack([data[tuple(slice(_r-(_p//2),_r+_p-(_p//2)) for _r,_p in zip(r,patch_size))] for r in zip(*rand_inds)]) for data in datas]
30
+
31
+ return res
32
+
33
+
34
+ def get_valid_inds(img, patch_size, patch_filter=None):
35
+ """
36
+ Returns all indices of an image that
37
+ - can be used as center points for sampling patches of a given patch_size, and
38
+ - are part of the boolean mask given by the function patch_filter (if provided)
39
+
40
+ img: np.ndarray
41
+ patch_size: tuple of ints
42
+ the width of patches per img dimension,
43
+ patch_filter: None or callable
44
+ a function with signature patch_filter(img, patch_size) returning a boolean mask
45
+ """
46
+
47
+ len(patch_size)==img.ndim or _raise(ValueError())
48
+
49
+ if not all(( 0 < s <= d for s,d in zip(patch_size,img.shape))):
50
+ raise ValueError("patch_size %s negative or larger than image shape %s along some dimensions" % (str(patch_size), str(img.shape)))
51
+
52
+ if patch_filter is None:
53
+ # only cut border indices (which is faster)
54
+ patch_mask = np.ones(img.shape,dtype=bool)
55
+ valid_inds = tuple(np.arange(p // 2, s - p + p // 2 + 1).astype(np.uint32) for p, s in zip(patch_size, img.shape))
56
+ valid_inds = tuple(s.ravel() for s in np.meshgrid(*valid_inds, indexing='ij'))
57
+ else:
58
+ patch_mask = patch_filter(img, patch_size)
59
+
60
+ # get the valid indices
61
+ border_slices = tuple([slice(p // 2, s - p + p // 2 + 1) for p, s in zip(patch_size, img.shape)])
62
+ valid_inds = np.where(patch_mask[border_slices])
63
+ valid_inds = tuple((v + s.start).astype(np.uint32) for s, v in zip(border_slices, valid_inds))
64
+
65
+ return valid_inds
stardist_pkg/utils.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import print_function, unicode_literals, absolute_import, division
2
+
3
+ import numpy as np
4
+ import warnings
5
+ import os
6
+ import datetime
7
+ from tqdm import tqdm
8
+ from collections import defaultdict
9
+ from zipfile import ZipFile, ZIP_DEFLATED
10
+ from scipy.ndimage.morphology import distance_transform_edt, binary_fill_holes
11
+ from scipy.ndimage.measurements import find_objects
12
+ from scipy.optimize import minimize_scalar
13
+ from skimage.measure import regionprops
14
+ from csbdeep.utils import _raise
15
+ from csbdeep.utils.six import Path
16
+ from collections.abc import Iterable
17
+
18
+ from .matching import matching_dataset, _check_label_array
19
+
20
+
21
+ try:
22
+ from edt import edt
23
+ _edt_available = True
24
+ try: _edt_parallel_max = len(os.sched_getaffinity(0))
25
+ except: _edt_parallel_max = 128
26
+ _edt_parallel_default = 4
27
+ _edt_parallel = os.environ.get('STARDIST_EDT_NUM_THREADS', _edt_parallel_default)
28
+ try:
29
+ _edt_parallel = min(_edt_parallel_max, int(_edt_parallel))
30
+ except ValueError as e:
31
+ warnings.warn(f"Invalid value ({_edt_parallel}) for STARDIST_EDT_NUM_THREADS. Using default value ({_edt_parallel_default}) instead.")
32
+ _edt_parallel = _edt_parallel_default
33
+ del _edt_parallel_default, _edt_parallel_max
34
+ except ImportError:
35
+ _edt_available = False
36
+ # warnings.warn("Could not find package edt... \nConsider installing it with \n pip install edt\nto improve training data generation performance.")
37
+ pass
38
+
39
+
40
+ def gputools_available():
41
+ try:
42
+ import gputools
43
+ except:
44
+ return False
45
+ return True
46
+
47
+
48
+ def path_absolute(path_relative):
49
+ """ Get absolute path to resource"""
50
+ base_path = os.path.abspath(os.path.dirname(__file__))
51
+ return os.path.join(base_path, path_relative)
52
+
53
+
54
+ def _is_power_of_2(i):
55
+ assert i > 0
56
+ e = np.log2(i)
57
+ return e == int(e)
58
+
59
+
60
+ def _normalize_grid(grid,n):
61
+ try:
62
+ grid = tuple(grid)
63
+ (len(grid) == n and
64
+ all(map(np.isscalar,grid)) and
65
+ all(map(_is_power_of_2,grid))) or _raise(TypeError())
66
+ return tuple(int(g) for g in grid)
67
+ except (TypeError, AssertionError):
68
+ raise ValueError("grid = {grid} must be a list/tuple of length {n} with values that are power of 2".format(grid=grid, n=n))
69
+
70
+
71
+ def edt_prob(lbl_img, anisotropy=None):
72
+ if _edt_available:
73
+ return _edt_prob_edt(lbl_img, anisotropy=anisotropy)
74
+ else:
75
+ # warnings.warn("Could not find package edt... \nConsider installing it with \n pip install edt\nto improve training data generation performance.")
76
+ return _edt_prob_scipy(lbl_img, anisotropy=anisotropy)
77
+
78
+ def _edt_prob_edt(lbl_img, anisotropy=None):
79
+ """Perform EDT on each labeled object and normalize.
80
+ Internally uses https://github.com/seung-lab/euclidean-distance-transform-3d
81
+ that can handle multiple labels at once
82
+ """
83
+ lbl_img = np.ascontiguousarray(lbl_img)
84
+ constant_img = lbl_img.min() == lbl_img.max() and lbl_img.flat[0] > 0
85
+ if constant_img:
86
+ warnings.warn("EDT of constant label image is ill-defined. (Assuming background around it.)")
87
+ # we just need to compute the edt once but then normalize it for each object
88
+ prob = edt(lbl_img, anisotropy=anisotropy, black_border=constant_img, parallel=_edt_parallel)
89
+ objects = find_objects(lbl_img)
90
+ for i,sl in enumerate(objects,1):
91
+ # i: object label id, sl: slices of object in lbl_img
92
+ if sl is None: continue
93
+ _mask = lbl_img[sl]==i
94
+ # normalize it
95
+ prob[sl][_mask] /= np.max(prob[sl][_mask]+1e-10)
96
+ return prob
97
+
98
+ def _edt_prob_scipy(lbl_img, anisotropy=None):
99
+ """Perform EDT on each labeled object and normalize."""
100
+ def grow(sl,interior):
101
+ return tuple(slice(s.start-int(w[0]),s.stop+int(w[1])) for s,w in zip(sl,interior))
102
+ def shrink(interior):
103
+ return tuple(slice(int(w[0]),(-1 if w[1] else None)) for w in interior)
104
+ constant_img = lbl_img.min() == lbl_img.max() and lbl_img.flat[0] > 0
105
+ if constant_img:
106
+ lbl_img = np.pad(lbl_img, ((1,1),)*lbl_img.ndim, mode='constant')
107
+ warnings.warn("EDT of constant label image is ill-defined. (Assuming background around it.)")
108
+ objects = find_objects(lbl_img)
109
+ prob = np.zeros(lbl_img.shape,np.float32)
110
+ for i,sl in enumerate(objects,1):
111
+ # i: object label id, sl: slices of object in lbl_img
112
+ if sl is None: continue
113
+ interior = [(s.start>0,s.stop<sz) for s,sz in zip(sl,lbl_img.shape)]
114
+ # 1. grow object slice by 1 for all interior object bounding boxes
115
+ # 2. perform (correct) EDT for object with label id i
116
+ # 3. extract EDT for object of original slice and normalize
117
+ # 4. store edt for object only for pixels of given label id i
118
+ shrink_slice = shrink(interior)
119
+ grown_mask = lbl_img[grow(sl,interior)]==i
120
+ mask = grown_mask[shrink_slice]
121
+ edt = distance_transform_edt(grown_mask, sampling=anisotropy)[shrink_slice][mask]
122
+ prob[sl][mask] = edt/(np.max(edt)+1e-10)
123
+ if constant_img:
124
+ prob = prob[(slice(1,-1),)*lbl_img.ndim].copy()
125
+ return prob
126
+
127
+
128
+ def _fill_label_holes(lbl_img, **kwargs):
129
+ lbl_img_filled = np.zeros_like(lbl_img)
130
+ for l in (set(np.unique(lbl_img)) - set([0])):
131
+ mask = lbl_img==l
132
+ mask_filled = binary_fill_holes(mask,**kwargs)
133
+ lbl_img_filled[mask_filled] = l
134
+ return lbl_img_filled
135
+
136
+
137
+ def fill_label_holes(lbl_img, **kwargs):
138
+ """Fill small holes in label image."""
139
+ # TODO: refactor 'fill_label_holes' and 'edt_prob' to share code
140
+ def grow(sl,interior):
141
+ return tuple(slice(s.start-int(w[0]),s.stop+int(w[1])) for s,w in zip(sl,interior))
142
+ def shrink(interior):
143
+ return tuple(slice(int(w[0]),(-1 if w[1] else None)) for w in interior)
144
+ objects = find_objects(lbl_img)
145
+ lbl_img_filled = np.zeros_like(lbl_img)
146
+ for i,sl in enumerate(objects,1):
147
+ if sl is None: continue
148
+ interior = [(s.start>0,s.stop<sz) for s,sz in zip(sl,lbl_img.shape)]
149
+ shrink_slice = shrink(interior)
150
+ grown_mask = lbl_img[grow(sl,interior)]==i
151
+ mask_filled = binary_fill_holes(grown_mask,**kwargs)[shrink_slice]
152
+ lbl_img_filled[sl][mask_filled] = i
153
+ return lbl_img_filled
154
+
155
+
156
+ def sample_points(n_samples, mask, prob=None, b=2):
157
+ """sample points to draw some of the associated polygons"""
158
+ if b is not None and b > 0:
159
+ # ignore image boundary, since predictions may not be reliable
160
+ mask_b = np.zeros_like(mask)
161
+ mask_b[b:-b,b:-b] = True
162
+ else:
163
+ mask_b = True
164
+
165
+ points = np.nonzero(mask & mask_b)
166
+
167
+ if prob is not None:
168
+ # weighted sampling via prob
169
+ w = prob[points[0],points[1]].astype(np.float64)
170
+ w /= np.sum(w)
171
+ ind = np.random.choice(len(points[0]), n_samples, replace=True, p=w)
172
+ else:
173
+ ind = np.random.choice(len(points[0]), n_samples, replace=True)
174
+
175
+ points = points[0][ind], points[1][ind]
176
+ points = np.stack(points,axis=-1)
177
+ return points
178
+
179
+
180
+ def calculate_extents(lbl, func=np.median):
181
+ """ Aggregate bounding box sizes of objects in label images. """
182
+ if (isinstance(lbl,np.ndarray) and lbl.ndim==4) or (not isinstance(lbl,np.ndarray) and isinstance(lbl,Iterable)):
183
+ return func(np.stack([calculate_extents(_lbl,func) for _lbl in lbl], axis=0), axis=0)
184
+
185
+ n = lbl.ndim
186
+ n in (2,3) or _raise(ValueError("label image should be 2- or 3-dimensional (or pass a list of these)"))
187
+
188
+ regs = regionprops(lbl)
189
+ if len(regs) == 0:
190
+ return np.zeros(n)
191
+ else:
192
+ extents = np.array([np.array(r.bbox[n:])-np.array(r.bbox[:n]) for r in regs])
193
+ return func(extents, axis=0)
194
+
195
+
196
+ def polyroi_bytearray(x,y,pos=None,subpixel=True):
197
+ """ Byte array of polygon roi with provided x and y coordinates
198
+ See https://github.com/imagej/imagej1/blob/master/ij/io/RoiDecoder.java
199
+ """
200
+ import struct
201
+ def _int16(x):
202
+ return int(x).to_bytes(2, byteorder='big', signed=True)
203
+ def _uint16(x):
204
+ return int(x).to_bytes(2, byteorder='big', signed=False)
205
+ def _int32(x):
206
+ return int(x).to_bytes(4, byteorder='big', signed=True)
207
+ def _float(x):
208
+ return struct.pack(">f", x)
209
+
210
+ subpixel = bool(subpixel)
211
+ # add offset since pixel center is at (0.5,0.5) in ImageJ
212
+ x_raw = np.asarray(x).ravel() + 0.5
213
+ y_raw = np.asarray(y).ravel() + 0.5
214
+ x = np.round(x_raw)
215
+ y = np.round(y_raw)
216
+ assert len(x) == len(y)
217
+ top, left, bottom, right = y.min(), x.min(), y.max(), x.max() # bbox
218
+
219
+ n_coords = len(x)
220
+ bytes_header = 64
221
+ bytes_total = bytes_header + n_coords*2*2 + subpixel*n_coords*2*4
222
+ B = [0] * bytes_total
223
+ B[ 0: 4] = map(ord,'Iout') # magic start
224
+ B[ 4: 6] = _int16(227) # version
225
+ B[ 6: 8] = _int16(0) # roi type (0 = polygon)
226
+ B[ 8:10] = _int16(top) # bbox top
227
+ B[10:12] = _int16(left) # bbox left
228
+ B[12:14] = _int16(bottom) # bbox bottom
229
+ B[14:16] = _int16(right) # bbox right
230
+ B[16:18] = _uint16(n_coords) # number of coordinates
231
+ if subpixel:
232
+ B[50:52] = _int16(128) # subpixel resolution (option flag)
233
+ if pos is not None:
234
+ B[56:60] = _int32(pos) # position (C, Z, or T)
235
+
236
+ for i,(_x,_y) in enumerate(zip(x,y)):
237
+ xs = bytes_header + 2*i
238
+ ys = xs + 2*n_coords
239
+ B[xs:xs+2] = _int16(_x - left)
240
+ B[ys:ys+2] = _int16(_y - top)
241
+
242
+ if subpixel:
243
+ base1 = bytes_header + n_coords*2*2
244
+ base2 = base1 + n_coords*4
245
+ for i,(_x,_y) in enumerate(zip(x_raw,y_raw)):
246
+ xs = base1 + 4*i
247
+ ys = base2 + 4*i
248
+ B[xs:xs+4] = _float(_x)
249
+ B[ys:ys+4] = _float(_y)
250
+
251
+ return bytearray(B)
252
+
253
+
254
+ def export_imagej_rois(fname, polygons, set_position=True, subpixel=True, compression=ZIP_DEFLATED):
255
+ """ polygons assumed to be a list of arrays with shape (id,2,c) """
256
+
257
+ if isinstance(polygons,np.ndarray):
258
+ polygons = (polygons,)
259
+
260
+ fname = Path(fname)
261
+ if fname.suffix == '.zip':
262
+ fname = fname.with_suffix('')
263
+
264
+ with ZipFile(str(fname)+'.zip', mode='w', compression=compression) as roizip:
265
+ for pos,polygroup in enumerate(polygons,start=1):
266
+ for i,poly in enumerate(polygroup,start=1):
267
+ roi = polyroi_bytearray(poly[1],poly[0], pos=(pos if set_position else None), subpixel=subpixel)
268
+ roizip.writestr('{pos:03d}_{i:03d}.roi'.format(pos=pos,i=i), roi)
269
+
270
+
271
+ def optimize_threshold(Y, Yhat, model, nms_thresh, measure='accuracy', iou_threshs=[0.3,0.5,0.7], bracket=None, tol=1e-2, maxiter=20, verbose=1):
272
+ """ Tune prob_thresh for provided (fixed) nms_thresh to maximize matching score (for given measure and averaged over iou_threshs). """
273
+ np.isscalar(nms_thresh) or _raise(ValueError("nms_thresh must be a scalar"))
274
+ iou_threshs = [iou_threshs] if np.isscalar(iou_threshs) else iou_threshs
275
+ values = dict()
276
+
277
+ if bracket is None:
278
+ max_prob = max([np.max(prob) for prob, dist in Yhat])
279
+ bracket = max_prob/2, max_prob
280
+ # print("bracket =", bracket)
281
+
282
+ with tqdm(total=maxiter, disable=(verbose!=1), desc="NMS threshold = %g" % nms_thresh) as progress:
283
+
284
+ def fn(thr):
285
+ prob_thresh = np.clip(thr, *bracket)
286
+ value = values.get(prob_thresh)
287
+ if value is None:
288
+ Y_instances = [model._instances_from_prediction(y.shape, *prob_dist, prob_thresh=prob_thresh, nms_thresh=nms_thresh)[0] for y,prob_dist in zip(Y,Yhat)]
289
+ stats = matching_dataset(Y, Y_instances, thresh=iou_threshs, show_progress=False, parallel=True)
290
+ values[prob_thresh] = value = np.mean([s._asdict()[measure] for s in stats])
291
+ if verbose > 1:
292
+ print("{now} thresh: {prob_thresh:f} {measure}: {value:f}".format(
293
+ now = datetime.datetime.now().strftime('%H:%M:%S'),
294
+ prob_thresh = prob_thresh,
295
+ measure = measure,
296
+ value = value,
297
+ ), flush=True)
298
+ else:
299
+ progress.update()
300
+ progress.set_postfix_str("{prob_thresh:.3f} -> {value:.3f}".format(prob_thresh=prob_thresh, value=value))
301
+ progress.refresh()
302
+ return -value
303
+
304
+ opt = minimize_scalar(fn, method='golden', bracket=bracket, tol=tol, options={'maxiter': maxiter})
305
+
306
+ verbose > 1 and print('\n',opt, flush=True)
307
+ return opt.x, -opt.fun
308
+
309
+
310
+ def _invert_dict(d):
311
+ """ return v-> [k_1,k_2,k_3....] for k,v in d"""
312
+ res = defaultdict(list)
313
+ for k,v in d.items():
314
+ res[v].append(k)
315
+ return res
316
+
317
+
318
+ def mask_to_categorical(y, n_classes, classes, return_cls_dict=False):
319
+ """generates a multi-channel categorical class map
320
+
321
+ Parameters
322
+ ----------
323
+ y : n-dimensional ndarray
324
+ integer label array
325
+ n_classes : int
326
+ Number of different classes (without background)
327
+ classes: dict, integer, or None
328
+ the label to class assignment
329
+ can be
330
+ - dict {label -> class_id}
331
+ the value of class_id can be
332
+ 0 -> background class
333
+ 1...n_classes -> the respective object class (1 ... n_classes)
334
+ None -> ignore object (prob is set to -1 for the pixels of the object, except for background class)
335
+ - single integer value or None -> broadcast value to all labels
336
+
337
+ Returns
338
+ -------
339
+ probability map of shape y.shape+(n_classes+1,) (first channel is background)
340
+
341
+ """
342
+
343
+ _check_label_array(y, 'y')
344
+ if not (np.issubdtype(type(n_classes), np.integer) and n_classes>=1):
345
+ raise ValueError(f"n_classes is '{n_classes}' but should be a positive integer")
346
+
347
+ y_labels = np.unique(y[y>0]).tolist()
348
+
349
+ # build dict class_id -> labels (inverse of classes)
350
+ if np.issubdtype(type(classes), np.integer) or classes is None:
351
+ classes = dict((k,classes) for k in y_labels)
352
+ elif isinstance(classes, dict):
353
+ pass
354
+ else:
355
+ raise ValueError("classes should be dict, single scalar, or None!")
356
+
357
+ if not set(y_labels).issubset(set(classes.keys())):
358
+ raise ValueError(f"all gt labels should be present in class dict provided \ngt_labels found\n{set(y_labels)}\nclass dict labels provided\n{set(classes.keys())}")
359
+
360
+ cls_dict = _invert_dict(classes)
361
+
362
+ # prob map
363
+ y_mask = np.zeros(y.shape+(n_classes+1,), np.float32)
364
+
365
+ for cls, labels in cls_dict.items():
366
+ if cls is None:
367
+ # prob == -1 will be used in the loss to ignore object
368
+ y_mask[np.isin(y, labels)] = -1
369
+ elif np.issubdtype(type(cls), np.integer) and 0 <= cls <= n_classes:
370
+ y_mask[...,cls] = np.isin(y, labels)
371
+ else:
372
+ raise ValueError(f"Wrong class id '{cls}' (for n_classes={n_classes})")
373
+
374
+ # set 0/1 background prob (unaffected by None values for class ids)
375
+ y_mask[...,0] = (y==0)
376
+
377
+ if return_cls_dict:
378
+ return y_mask, cls_dict
379
+ else:
380
+ return y_mask
381
+
382
+
383
+ def _is_floatarray(x):
384
+ return isinstance(x.dtype.type(0),np.floating)
385
+
386
+
387
+ def abspath(root, relpath):
388
+ from pathlib import Path
389
+ root = Path(root)
390
+ if root.is_dir():
391
+ path = root/relpath
392
+ else:
393
+ path = root.parent/relpath
394
+ return str(path.absolute())
stardist_pkg/version.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __version__ = '0.8.3'