cellfinder 1.1.3__py3-none-any.whl → 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of cellfinder might be problematic. Click here for more details.

Files changed (34) hide show
  1. cellfinder/__init__.py +21 -12
  2. cellfinder/core/classify/classify.py +13 -6
  3. cellfinder/core/classify/cube_generator.py +27 -11
  4. cellfinder/core/classify/resnet.py +9 -6
  5. cellfinder/core/classify/tools.py +13 -11
  6. cellfinder/core/detect/detect.py +12 -1
  7. cellfinder/core/detect/filters/volume/ball_filter.py +198 -113
  8. cellfinder/core/detect/filters/volume/structure_detection.py +105 -41
  9. cellfinder/core/detect/filters/volume/structure_splitting.py +1 -1
  10. cellfinder/core/detect/filters/volume/volume_filter.py +48 -49
  11. cellfinder/core/download/cli.py +39 -32
  12. cellfinder/core/download/download.py +44 -56
  13. cellfinder/core/main.py +53 -68
  14. cellfinder/core/tools/prep.py +12 -20
  15. cellfinder/core/tools/source_files.py +5 -3
  16. cellfinder/core/tools/system.py +10 -0
  17. cellfinder/core/train/train_yml.py +29 -27
  18. cellfinder/napari/curation.py +1 -1
  19. cellfinder/napari/detect/detect.py +259 -58
  20. cellfinder/napari/detect/detect_containers.py +11 -1
  21. cellfinder/napari/detect/thread_worker.py +16 -2
  22. cellfinder/napari/train/train.py +2 -9
  23. cellfinder/napari/train/train_containers.py +3 -3
  24. cellfinder/napari/utils.py +88 -47
  25. {cellfinder-1.1.3.dist-info → cellfinder-1.3.0.dist-info}/METADATA +12 -11
  26. {cellfinder-1.1.3.dist-info → cellfinder-1.3.0.dist-info}/RECORD +30 -34
  27. cellfinder/core/download/models.py +0 -49
  28. cellfinder/core/tools/IO.py +0 -48
  29. cellfinder/core/tools/tf.py +0 -46
  30. cellfinder/napari/images/brainglobe.png +0 -0
  31. {cellfinder-1.1.3.dist-info → cellfinder-1.3.0.dist-info}/LICENSE +0 -0
  32. {cellfinder-1.1.3.dist-info → cellfinder-1.3.0.dist-info}/WHEEL +0 -0
  33. {cellfinder-1.1.3.dist-info → cellfinder-1.3.0.dist-info}/entry_points.txt +0 -0
  34. {cellfinder-1.1.3.dist-info → cellfinder-1.3.0.dist-info}/top_level.txt +0 -0
@@ -1,12 +1,83 @@
1
+ from functools import lru_cache
2
+
1
3
  import numpy as np
2
- from numba import njit
4
+ from numba import njit, objmode, prange
5
+ from numba.core import types
6
+ from numba.experimental import jitclass
3
7
 
4
8
  from cellfinder.core.tools.array_operations import bin_mean_3d
5
9
  from cellfinder.core.tools.geometry import make_sphere
6
10
 
7
11
  DEBUG = False
8
12
 
13
+ uint32_3d_type = types.uint32[:, :, :]
14
+ bool_3d_type = types.bool_[:, :, :]
15
+ float_3d_type = types.float64[:, :, :]
16
+
17
+
18
+ @lru_cache(maxsize=50)
19
+ def get_kernel(ball_xy_size: int, ball_z_size: int) -> np.ndarray:
20
+ # Create a spherical kernel.
21
+ #
22
+ # This is done by:
23
+ # 1. Generating a binary sphere at a resolution *upscale_factor* larger
24
+ # than desired.
25
+ # 2. Downscaling the binary sphere to get a 'fuzzy' sphere at the
26
+ # original intended scale
27
+ upscale_factor: int = 7
28
+ upscaled_kernel_shape = (
29
+ upscale_factor * ball_xy_size,
30
+ upscale_factor * ball_xy_size,
31
+ upscale_factor * ball_z_size,
32
+ )
33
+ upscaled_ball_centre_position = (
34
+ np.floor(upscaled_kernel_shape[0] / 2),
35
+ np.floor(upscaled_kernel_shape[1] / 2),
36
+ np.floor(upscaled_kernel_shape[2] / 2),
37
+ )
38
+ upscaled_ball_radius = upscaled_kernel_shape[0] / 2.0
39
+
40
+ sphere_kernel = make_sphere(
41
+ upscaled_kernel_shape,
42
+ upscaled_ball_radius,
43
+ upscaled_ball_centre_position,
44
+ )
45
+ sphere_kernel = sphere_kernel.astype(np.float64)
46
+ kernel = bin_mean_3d(
47
+ sphere_kernel,
48
+ bin_height=upscale_factor,
49
+ bin_width=upscale_factor,
50
+ bin_depth=upscale_factor,
51
+ )
52
+
53
+ assert (
54
+ kernel.shape[2] == ball_z_size
55
+ ), "Kernel z dimension should be {}, got {}".format(
56
+ ball_z_size, kernel.shape[2]
57
+ )
58
+
59
+ return kernel
60
+
61
+
62
+ # volume indices/size is 64 bit for very large brains(!)
63
+ spec = [
64
+ ("ball_xy_size", types.uint32),
65
+ ("ball_z_size", types.uint32),
66
+ ("tile_step_width", types.uint64),
67
+ ("tile_step_height", types.uint64),
68
+ ("THRESHOLD_VALUE", types.uint32),
69
+ ("SOMA_CENTRE_VALUE", types.uint32),
70
+ ("overlap_fraction", types.float64),
71
+ ("overlap_threshold", types.float64),
72
+ ("middle_z_idx", types.uint32),
73
+ ("_num_z_added", types.uint32),
74
+ ("kernel", float_3d_type),
75
+ ("volume", uint32_3d_type),
76
+ ("inside_brain_tiles", bool_3d_type),
77
+ ]
78
+
9
79
 
80
+ @jitclass(spec=spec)
10
81
  class BallFilter:
11
82
  """
12
83
  A 3D ball filter.
@@ -62,72 +133,39 @@ class BallFilter:
62
133
  self.THRESHOLD_VALUE = threshold_value
63
134
  self.SOMA_CENTRE_VALUE = soma_centre_value
64
135
 
65
- # Create a spherical kernel.
66
- #
67
- # This is done by:
68
- # 1. Generating a binary sphere at a resolution *upscale_factor* larger
69
- # than desired.
70
- # 2. Downscaling the binary sphere to get a 'fuzzy' sphere at the
71
- # original intended scale
72
- upscale_factor: int = 7
73
- upscaled_kernel_shape = (
74
- upscale_factor * ball_xy_size,
75
- upscale_factor * ball_xy_size,
76
- upscale_factor * ball_z_size,
77
- )
78
- upscaled_ball_centre_position = (
79
- np.floor(upscaled_kernel_shape[0] / 2),
80
- np.floor(upscaled_kernel_shape[1] / 2),
81
- np.floor(upscaled_kernel_shape[2] / 2),
82
- )
83
- upscaled_ball_radius = upscaled_kernel_shape[0] / 2.0
84
- sphere_kernel = make_sphere(
85
- upscaled_kernel_shape,
86
- upscaled_ball_radius,
87
- upscaled_ball_centre_position,
88
- )
89
- sphere_kernel = sphere_kernel.astype(np.float64)
90
- self.kernel = bin_mean_3d(
91
- sphere_kernel,
92
- bin_height=upscale_factor,
93
- bin_width=upscale_factor,
94
- bin_depth=upscale_factor,
95
- )
96
-
97
- assert (
98
- self.kernel.shape[2] == ball_z_size
99
- ), "Kernel z dimension should be {}, got {}".format(
100
- ball_z_size, self.kernel.shape[2]
101
- )
136
+ # getting kernel is not jitted
137
+ with objmode(kernel=float_3d_type):
138
+ kernel = get_kernel(ball_xy_size, ball_z_size)
139
+ self.kernel = kernel
102
140
 
103
141
  self.overlap_threshold = np.sum(self.overlap_fraction * self.kernel)
104
142
 
105
143
  # Stores the current planes that are being filtered
144
+ # first axis is z for faster rotating the z-axis
106
145
  self.volume = np.empty(
107
- (plane_width, plane_height, ball_z_size), dtype=np.uint32
146
+ (ball_z_size, plane_width, plane_height),
147
+ dtype=np.uint32,
108
148
  )
109
149
  # Index of the middle plane in the volume
110
150
  self.middle_z_idx = int(np.floor(ball_z_size / 2))
151
+ self._num_z_added = 0
111
152
 
112
- # TODO: lazy initialisation
153
+ # first axis is z
113
154
  self.inside_brain_tiles = np.empty(
114
155
  (
156
+ ball_z_size,
115
157
  int(np.ceil(plane_width / tile_step_width)),
116
158
  int(np.ceil(plane_height / tile_step_height)),
117
- ball_z_size,
118
159
  ),
119
- dtype=bool,
160
+ dtype=np.bool_,
120
161
  )
121
- # Stores the z-index in volume at which new planes are inserted when
122
- # append() is called
123
- self.__current_z = -1
124
162
 
125
163
  @property
126
164
  def ready(self) -> bool:
127
165
  """
128
166
  Return `True` if enough planes have been appended to run the filter.
129
167
  """
130
- return self.__current_z == self.ball_z_size - 1
168
+ return self._num_z_added >= self.ball_z_size
131
169
 
132
170
  def append(self, plane: np.ndarray, mask: np.ndarray) -> None:
133
171
  """
@@ -135,76 +173,106 @@ class BallFilter:
135
173
  """
136
174
  if DEBUG:
137
175
  assert [e for e in plane.shape[:2]] == [
138
- e for e in self.volume.shape[:2]
176
+ e for e in self.volume.shape[1:]
139
177
  ], 'plane shape mismatch, expected "{}", got "{}"'.format(
140
- [e for e in self.volume.shape[:2]],
178
+ [e for e in self.volume.shape[1:]],
141
179
  [e for e in plane.shape[:2]],
142
180
  )
143
181
  assert [e for e in mask.shape[:2]] == [
144
- e for e in self.inside_brain_tiles.shape[:2]
182
+ e for e in self.inside_brain_tiles.shape[1:]
145
183
  ], 'mask shape mismatch, expected"{}", got {}"'.format(
146
- [e for e in self.inside_brain_tiles.shape[:2]],
184
+ [e for e in self.inside_brain_tiles.shape[1:]],
147
185
  [e for e in mask.shape[:2]],
148
186
  )
149
- if not self.ready:
150
- self.__current_z += 1
151
- else:
187
+
188
+ if self.ready:
152
189
  # Shift everything down by one to make way for the new plane
153
- self.volume = np.roll(
154
- self.volume, -1, axis=2
155
- ) # WARNING: not in place
156
- self.inside_brain_tiles = np.roll(
157
- self.inside_brain_tiles, -1, axis=2
158
- )
190
+ # this is faster than np.roll, especially with z-axis first
191
+ self.volume[:-1, :, :] = self.volume[1:, :, :]
192
+ self.inside_brain_tiles[:-1, :, :] = self.inside_brain_tiles[
193
+ 1:, :, :
194
+ ]
195
+
196
+ # index for *next* slice is num we added *so far* until max
197
+ idx = min(self._num_z_added, self.ball_z_size - 1)
198
+ self._num_z_added += 1
199
+
159
200
  # Add the new plane to the top of volume and inside_brain_tiles
160
- self.volume[:, :, self.__current_z] = plane[:, :]
161
- self.inside_brain_tiles[:, :, self.__current_z] = mask[:, :]
201
+ self.volume[idx, :, :] = plane
202
+ self.inside_brain_tiles[idx, :, :] = mask
162
203
 
163
204
  def get_middle_plane(self) -> np.ndarray:
164
205
  """
165
206
  Get the plane in the middle of self.volume.
166
207
  """
167
- z = self.middle_z_idx
168
- return np.array(self.volume[:, :, z], dtype=np.uint32)
208
+ return self.volume[self.middle_z_idx, :, :].copy()
169
209
 
170
- def walk(self) -> None: # Highly optimised because most time critical
210
+ def walk(self, parallel: bool = False) -> None:
211
+ # **don't** pass parallel as keyword arg - numba struggles with it
212
+ # Highly optimised because most time critical
171
213
  ball_radius = self.ball_xy_size // 2
172
214
  # Get extents of image that are covered by tiles
173
215
  tile_mask_covered_img_width = (
174
- self.inside_brain_tiles.shape[0] * self.tile_step_width
216
+ self.inside_brain_tiles.shape[1] * self.tile_step_width
175
217
  )
176
218
  tile_mask_covered_img_height = (
177
- self.inside_brain_tiles.shape[1] * self.tile_step_height
219
+ self.inside_brain_tiles.shape[2] * self.tile_step_height
178
220
  )
179
221
  # Get maximum offsets for the ball
180
222
  max_width = tile_mask_covered_img_width - self.ball_xy_size
181
223
  max_height = tile_mask_covered_img_height - self.ball_xy_size
182
224
 
183
- _walk(
184
- max_height,
185
- max_width,
186
- self.tile_step_width,
187
- self.tile_step_height,
188
- self.inside_brain_tiles,
189
- self.volume,
190
- self.kernel,
191
- ball_radius,
192
- self.middle_z_idx,
193
- self.overlap_threshold,
194
- self.THRESHOLD_VALUE,
195
- self.SOMA_CENTRE_VALUE,
196
- )
225
+ # we have to pass the raw volume so walk doesn't use its edits as it
226
+ # processes the volume. self.volume is the one edited in place
227
+ input_volume = self.volume.copy()
228
+
229
+ if parallel:
230
+ _walk_parallel(
231
+ max_height,
232
+ max_width,
233
+ self.tile_step_width,
234
+ self.tile_step_height,
235
+ self.inside_brain_tiles,
236
+ input_volume,
237
+ self.volume,
238
+ self.kernel,
239
+ ball_radius,
240
+ self.middle_z_idx,
241
+ self.overlap_threshold,
242
+ self.THRESHOLD_VALUE,
243
+ self.SOMA_CENTRE_VALUE,
244
+ )
245
+ else:
246
+ _walk_single(
247
+ max_height,
248
+ max_width,
249
+ self.tile_step_width,
250
+ self.tile_step_height,
251
+ self.inside_brain_tiles,
252
+ input_volume,
253
+ self.volume,
254
+ self.kernel,
255
+ ball_radius,
256
+ self.middle_z_idx,
257
+ self.overlap_threshold,
258
+ self.THRESHOLD_VALUE,
259
+ self.SOMA_CENTRE_VALUE,
260
+ )
197
261
 
198
262
 
199
263
  @njit(cache=True)
200
264
  def _cube_overlaps(
201
- cube: np.ndarray,
265
+ volume: np.ndarray,
266
+ x_start: int,
267
+ x_end: int,
268
+ y_start: int,
269
+ y_end: int,
202
270
  overlap_threshold: float,
203
- THRESHOLD_VALUE: int,
271
+ threshold_value: int,
204
272
  kernel: np.ndarray,
205
273
  ) -> bool: # Highly optimised because most time critical
206
274
  """
207
- For each pixel in cube that is greater than THRESHOLD_VALUE, sum
275
+ For each pixel in cube in volume that is greater than THRESHOLD_VALUE, sum
208
276
  up the corresponding pixels in *kernel*. If the total is less than
209
277
  overlap_threshold, return False, otherwise return True.
210
278
 
@@ -214,23 +282,26 @@ def _cube_overlaps(
214
282
 
215
283
  Parameters
216
284
  ----------
217
- cube :
285
+ volume :
218
286
  3D array.
287
+ x_start, x_end, y_start, y_end :
288
+ The start and end indices in volume that form the cube. End is
289
+ exclusive
219
290
  overlap_threshold :
220
291
  Threshold above which to return True.
221
- THRESHOLD_VALUE :
292
+ threshold_value :
222
293
  Value above which a pixel is marked as being part of a cell.
223
294
  kernel :
224
- 3D array, with the same shape as *cube*.
295
+ 3D array, with the same shape as *cube* in the volume.
225
296
  """
226
- current_overlap_value = 0
297
+ current_overlap_value = 0.0
227
298
 
228
- middle = np.floor(cube.shape[2] / 2) + 1
299
+ middle = np.floor(volume.shape[0] / 2) + 1
229
300
  halfway_overlap_thresh = (
230
301
  overlap_threshold * 0.4
231
302
  ) # FIXME: do not hard code value
232
303
 
233
- for z in range(cube.shape[2]):
304
+ for z in range(volume.shape[0]):
234
305
  # TODO: OPTIMISE: step from middle to outer boundaries to check
235
306
  # more data first
236
307
  #
@@ -238,11 +309,17 @@ def _cube_overlaps(
238
309
  # 0.4 * the overlap threshold, return
239
310
  if z == middle and current_overlap_value < halfway_overlap_thresh:
240
311
  return False # DEBUG: optimisation attempt
241
- for y in range(cube.shape[1]):
242
- for x in range(cube.shape[0]):
312
+
313
+ for y in range(y_start, y_end):
314
+ for x in range(x_start, x_end):
243
315
  # includes self.SOMA_CENTRE_VALUE
244
- if cube[x, y, z] >= THRESHOLD_VALUE:
245
- current_overlap_value += kernel[x, y, z]
316
+ if volume[z, x, y] >= threshold_value:
317
+ # x/y must be shifted in kernel because we x/y is relative
318
+ # to the full volume, so shift it to relative to the cube
319
+ current_overlap_value += kernel[
320
+ x - x_start, y - y_start, z
321
+ ]
322
+
246
323
  return current_overlap_value > overlap_threshold
247
324
 
248
325
 
@@ -260,23 +337,23 @@ def _is_tile_to_check(
260
337
  """
261
338
  x_in_mask = x // tile_step_width # TEST: test bounds (-1 range)
262
339
  y_in_mask = y // tile_step_height # TEST: test bounds (-1 range)
263
- return inside_brain_tiles[x_in_mask, y_in_mask, middle_z]
340
+ return inside_brain_tiles[middle_z, x_in_mask, y_in_mask]
264
341
 
265
342
 
266
- @njit
267
- def _walk(
343
+ def _walk_base(
268
344
  max_height: int,
269
345
  max_width: int,
270
346
  tile_step_width: int,
271
347
  tile_step_height: int,
272
348
  inside_brain_tiles: np.ndarray,
349
+ input_volume: np.ndarray,
273
350
  volume: np.ndarray,
274
351
  kernel: np.ndarray,
275
352
  ball_radius: int,
276
353
  middle_z: int,
277
354
  overlap_threshold: float,
278
- THRESHOLD_VALUE: int,
279
- SOMA_CENTRE_VALUE: int,
355
+ threshold_value: int,
356
+ soma_centre_value: int,
280
357
  ) -> None:
281
358
  """
282
359
  Scan through *volume*, and mark pixels where there are enough surrounding
@@ -289,23 +366,28 @@ def _walk(
289
366
  max_height, max_width :
290
367
  Maximum offsets for the ball filter.
291
368
  inside_brain_tiles :
292
- Array containing information on whether a tile is inside the brain
293
- or not. Tiles outside the brain are skipped.
369
+ 3d array containing information on whether a tile is
370
+ inside the brain or not. Tiles outside the brain are skipped.
371
+ input_volume :
372
+ 3D array containing the plane-filtered data passed to the function
373
+ before walking. volume is edited in place, so this is the original
374
+ volume to prevent the changes for some cubes affective other cubes
375
+ during a single walk call.
294
376
  volume :
295
- 3D array containing the plane-filtered data.
377
+ 3D array containing the plane-filtered data - edited in place.
296
378
  kernel :
297
379
  3D array
298
380
  ball_radius :
299
381
  Radius of the ball in the xy plane.
300
- SOMA_CENTRE_VALUE :
382
+ soma_centre_value :
301
383
  Value that is used to mark pixels in *volume*.
302
384
 
303
385
  Notes
304
386
  -----
305
387
  Warning: modifies volume in place!
306
388
  """
307
- for y in range(max_height):
308
- for x in range(max_width):
389
+ for y in prange(max_height):
390
+ for x in prange(max_width):
309
391
  ball_centre_x = x + ball_radius
310
392
  ball_centre_y = y + ball_radius
311
393
  if _is_tile_to_check(
@@ -316,17 +398,20 @@ def _walk(
316
398
  tile_step_height,
317
399
  inside_brain_tiles,
318
400
  ):
319
- cube = volume[
320
- x : x + kernel.shape[0],
321
- y : y + kernel.shape[1],
322
- :,
323
- ]
324
401
  if _cube_overlaps(
325
- cube,
402
+ input_volume,
403
+ x,
404
+ x + kernel.shape[0],
405
+ y,
406
+ y + kernel.shape[1],
326
407
  overlap_threshold,
327
- THRESHOLD_VALUE,
408
+ threshold_value,
328
409
  kernel,
329
410
  ):
330
- volume[ball_centre_x, ball_centre_y, middle_z] = (
331
- SOMA_CENTRE_VALUE
411
+ volume[middle_z, ball_centre_x, ball_centre_y] = (
412
+ soma_centre_value
332
413
  )
414
+
415
+
416
+ _walk_parallel = njit(parallel=True)(_walk_base)
417
+ _walk_single = njit(parallel=False)(_walk_base)