dcnum 0.13.2__py3-none-any.whl → 0.23.1__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 dcnum might be problematic. Click here for more details.

Files changed (55) hide show
  1. dcnum/_version.py +2 -2
  2. dcnum/feat/__init__.py +2 -1
  3. dcnum/feat/event_extractor_manager_thread.py +67 -33
  4. dcnum/feat/feat_background/__init__.py +3 -12
  5. dcnum/feat/feat_background/base.py +80 -65
  6. dcnum/feat/feat_background/bg_copy.py +31 -0
  7. dcnum/feat/feat_background/bg_roll_median.py +38 -30
  8. dcnum/feat/feat_background/bg_sparse_median.py +96 -45
  9. dcnum/feat/feat_brightness/__init__.py +1 -0
  10. dcnum/feat/feat_brightness/bright_all.py +41 -6
  11. dcnum/feat/feat_contour/__init__.py +4 -0
  12. dcnum/feat/{feat_moments/mt_legacy.py → feat_contour/moments.py} +32 -8
  13. dcnum/feat/feat_contour/volume.py +174 -0
  14. dcnum/feat/feat_texture/__init__.py +1 -0
  15. dcnum/feat/feat_texture/tex_all.py +28 -1
  16. dcnum/feat/gate.py +92 -70
  17. dcnum/feat/queue_event_extractor.py +139 -70
  18. dcnum/logic/__init__.py +5 -0
  19. dcnum/logic/ctrl.py +794 -0
  20. dcnum/logic/job.py +184 -0
  21. dcnum/logic/json_encoder.py +19 -0
  22. dcnum/meta/__init__.py +1 -0
  23. dcnum/meta/paths.py +30 -0
  24. dcnum/meta/ppid.py +66 -9
  25. dcnum/read/__init__.py +1 -0
  26. dcnum/read/cache.py +109 -77
  27. dcnum/read/const.py +6 -4
  28. dcnum/read/hdf5_data.py +190 -31
  29. dcnum/read/mapped.py +87 -0
  30. dcnum/segm/__init__.py +6 -15
  31. dcnum/segm/segm_thresh.py +7 -14
  32. dcnum/segm/segm_torch/__init__.py +19 -0
  33. dcnum/segm/segm_torch/segm_torch_base.py +125 -0
  34. dcnum/segm/segm_torch/segm_torch_mpo.py +71 -0
  35. dcnum/segm/segm_torch/segm_torch_sto.py +88 -0
  36. dcnum/segm/segm_torch/torch_model.py +95 -0
  37. dcnum/segm/segm_torch/torch_postproc.py +93 -0
  38. dcnum/segm/segm_torch/torch_preproc.py +114 -0
  39. dcnum/segm/segmenter.py +245 -96
  40. dcnum/segm/segmenter_manager_thread.py +39 -28
  41. dcnum/segm/{segmenter_cpu.py → segmenter_mpo.py} +137 -43
  42. dcnum/segm/segmenter_sto.py +110 -0
  43. dcnum/write/__init__.py +3 -1
  44. dcnum/write/deque_writer_thread.py +15 -5
  45. dcnum/write/queue_collector_thread.py +14 -17
  46. dcnum/write/writer.py +225 -55
  47. {dcnum-0.13.2.dist-info → dcnum-0.23.1.dist-info}/METADATA +4 -2
  48. dcnum-0.23.1.dist-info/RECORD +55 -0
  49. {dcnum-0.13.2.dist-info → dcnum-0.23.1.dist-info}/WHEEL +1 -1
  50. dcnum/feat/feat_moments/__init__.py +0 -3
  51. dcnum/segm/segmenter_gpu.py +0 -45
  52. dcnum-0.13.2.dist-info/RECORD +0 -40
  53. /dcnum/feat/{feat_moments/ct_opencv.py → feat_contour/contour.py} +0 -0
  54. {dcnum-0.13.2.dist-info → dcnum-0.23.1.dist-info}/LICENSE +0 -0
  55. {dcnum-0.13.2.dist-info → dcnum-0.23.1.dist-info}/top_level.txt +0 -0
@@ -1,24 +1,21 @@
1
1
  import logging
2
- import multiprocessing as mp
3
2
  import queue
4
3
  import time
5
4
 
6
5
  import numpy as np
7
6
  from scipy import ndimage
8
7
 
9
- from .base import Background
10
-
11
- logger = logging.getLogger(__name__)
8
+ from ...read import HDF5Data
12
9
 
10
+ from .base import mp_spawn, Background
13
11
 
14
- # All subprocesses should use 'spawn' to avoid issues with threads
15
- # and 'fork' on POSIX systems.
16
- mp_spawn = mp.get_context('spawn')
12
+ logger = logging.getLogger(__name__)
17
13
 
18
14
 
19
15
  class BackgroundSparseMed(Background):
20
16
  def __init__(self, input_data, output_path, kernel_size=200,
21
17
  split_time=1., thresh_cleansing=0, frac_cleansing=.8,
18
+ offset_correction=True,
22
19
  compress=True, num_cpus=None):
23
20
  """Sparse median background correction with cleansing
24
21
 
@@ -61,6 +58,21 @@ class BackgroundSparseMed(Background):
61
58
  Fraction between 0 and 1 indicating how many background images
62
59
  must still be present after cleansing (in case the cleansing
63
60
  factor is too large). Set to 1 to disable cleansing altogether.
61
+ offset_correction: bool
62
+ The sparse median background correction produces one median
63
+ image for multiple input frames (BTW this also leads to very
64
+ efficient data storage with HDF5 data compression filters). In
65
+ case the input frames are subject to frame-by-frame brightness
66
+ variations (e.g. flickering of the illumination source), it
67
+ is useful to have an offset value per frame that can then be
68
+ used in a later step to perform a more accurate background
69
+ correction. This offset is computed here by taking a 20px wide
70
+ slice from each frame (where the channel wall is located)
71
+ and computing the median therein relative to the computed
72
+ background image. The data are written to the "bg_off" feature
73
+ in the output file alongside "image_bg". To obtain the
74
+ corrected background image, add "image_bg" and "bg_off".
75
+ Set this to False if you don't need the "bg_off" feature.
64
76
  compress: bool
65
77
  Whether to compress background data. Set this to False
66
78
  for faster processing.
@@ -76,7 +88,15 @@ class BackgroundSparseMed(Background):
76
88
  kernel_size=kernel_size,
77
89
  split_time=split_time,
78
90
  thresh_cleansing=thresh_cleansing,
79
- frac_cleansing=frac_cleansing)
91
+ frac_cleansing=frac_cleansing,
92
+ offset_correction=offset_correction,
93
+ )
94
+
95
+ if kernel_size > len(self.input_data):
96
+ logger.warning(
97
+ f"The kernel size {kernel_size} is too large for input data"
98
+ f"size {len(self.input_data)}. Setting it to input data size!")
99
+ kernel_size = len(self.input_data)
80
100
 
81
101
  #: kernel size used for median filtering
82
102
  self.kernel_size = kernel_size
@@ -86,31 +106,34 @@ class BackgroundSparseMed(Background):
86
106
  self.thresh_cleansing = thresh_cleansing
87
107
  #: keep at least this many background images from the series
88
108
  self.frac_cleansing = frac_cleansing
109
+ #: offset/flickering correction
110
+ self.offset_correction = offset_correction
89
111
 
90
112
  # time axis
91
113
  self.time = None
92
114
  if self.h5in is not None:
93
- if "time" in self.h5in["events"]:
115
+ hd = HDF5Data(self.h5in)
116
+ if "time" in hd:
94
117
  # use actual time from dataset
95
- self.time = self.h5in["/events/time"][:]
118
+ self.time = hd["time"][:]
96
119
  self.time -= self.time[0]
97
- elif "imaging:frame rate" in self.h5in.attrs:
98
- fr = self.h5in.attrs["imaging:frame rate"]
99
- if "frame" in self.h5in["/events"]:
120
+ elif "imaging:frame rate" in hd.meta:
121
+ fr = hd.meta["imaging:frame rate"]
122
+ if "frame" in hd:
100
123
  # compute time from frame rate and frame numbers
101
- self.time = self.h5in["/events/frame"] / fr
124
+ self.time = hd["frame"] / fr
102
125
  self.time -= self.time[0]
103
126
  else:
104
127
  # compute time using frame rate (approximate)
105
- dur = self.event_count / fr * 1.5
128
+ dur = self.image_count / fr * 1.5
106
129
  logger.info(f"Approximating duration: {dur/60:.1f}min")
107
- self.time = np.linspace(0, dur, self.event_count,
130
+ self.time = np.linspace(0, dur, self.image_count,
108
131
  endpoint=True)
109
132
  if self.time is None:
110
133
  # No HDF5 file or no information therein; Make an educated guess.
111
- dur = self.event_count / 3600 * 1.5
134
+ dur = self.image_count / 3600 * 1.5
112
135
  logger.info(f"Guessing duration: {dur/60:.1f}min")
113
- self.time = np.linspace(0, dur, self.event_count,
136
+ self.time = np.linspace(0, dur, self.image_count,
114
137
  endpoint=True)
115
138
 
116
139
  #: duration of the measurement
@@ -149,11 +172,11 @@ class BackgroundSparseMed(Background):
149
172
  #: queue for median computation jobs
150
173
  self.queue = mp_spawn.Queue()
151
174
  #: list of workers (processes)
152
- self.workers = [MedianWorkerSingle(self.queue,
153
- self.worker_counter,
154
- self.shared_input_raw,
155
- self.shared_output_raw,
156
- self.kernel_size)
175
+ self.workers = [WorkerSparseMed(self.queue,
176
+ self.worker_counter,
177
+ self.shared_input_raw,
178
+ self.shared_output_raw,
179
+ self.kernel_size)
157
180
  for _ in range(self.num_cpus)]
158
181
  [w.start() for w in self.workers]
159
182
 
@@ -172,11 +195,13 @@ class BackgroundSparseMed(Background):
172
195
  kernel_size: int = 200,
173
196
  split_time: float = 1.,
174
197
  thresh_cleansing: float = 0,
175
- frac_cleansing: float = .8):
198
+ frac_cleansing: float = .8,
199
+ offset_correction: bool = True,
200
+ ):
176
201
  """Initialize user-defined properties of this class
177
202
 
178
203
  This method primarily exists so that the CLI knows which
179
- keyword arguements can be passed to this class.
204
+ keyword arguments can be passed to this class.
180
205
 
181
206
  Parameters
182
207
  ----------
@@ -194,6 +219,21 @@ class BackgroundSparseMed(Background):
194
219
  Fraction between 0 and 1 indicating how many background images
195
220
  must still be present after cleansing (in case the cleansing
196
221
  factor is too large). Set to 1 to disable cleansing altogether.
222
+ offset_correction: bool
223
+ The sparse median background correction produces one median
224
+ image for multiple input frames (BTW this also leads to very
225
+ efficient data storage with HDF5 data compression filters). In
226
+ case the input frames are subject to frame-by-frame brightness
227
+ variations (e.g. flickering of the illumination source), it
228
+ is useful to have an offset value per frame that can then be
229
+ used in a later step to perform a more accurate background
230
+ correction. This offset is computed here by taking a 20px wide
231
+ slice from each frame (where the channel wall is located)
232
+ and computing the median therein relative to the computed
233
+ background image. The data are written to the "bg_off" feature
234
+ in the output file alongside "image_bg". To obtain the
235
+ corrected background image, add "image_bg" and "bg_off".
236
+ Set this to False if you don't need the "bg_off" feature.
197
237
  """
198
238
  assert kernel_size > 0
199
239
  assert split_time > 0
@@ -206,10 +246,7 @@ class BackgroundSparseMed(Background):
206
246
 
207
247
  # Compute initial background images (populates self.bg_images)
208
248
  for ii, ti in enumerate(self.step_times):
209
- print(f"Computing background {ii / self.step_times.size:.0%}",
210
- end="\r", flush=True)
211
249
  self.process_second(ii, ti)
212
- print("Computing background 100% ", flush=True)
213
250
 
214
251
  if self.frac_cleansing != 1:
215
252
  # The following algorithm finds background images that contain
@@ -271,16 +308,16 @@ class BackgroundSparseMed(Background):
271
308
  f"`thresh_cleansing` or `frac_cleansing`. The new "
272
309
  f"threshold is {thresh_fact / thresh}.")
273
310
 
274
- logger.info(f"Removed {frac_remove:.2%} of the background series")
311
+ logger.info(f"Cleansed {frac_remove:.2%}")
275
312
  step_times = self.step_times[used]
276
313
  bg_images = self.bg_images[used]
277
314
  else:
278
- logger.info("Background series cleansing disabled.")
315
+ logger.info("Background series cleansing disabled")
279
316
  step_times = self.step_times
280
317
  bg_images = self.bg_images
281
318
 
282
319
  # Assign each frame to a certain background index
283
- bg_idx = np.zeros(self.event_count, dtype=int)
320
+ bg_idx = np.zeros(self.image_count, dtype=int)
284
321
  idx0 = 0
285
322
  idx1 = None
286
323
  for ii in range(len(step_times)):
@@ -292,26 +329,38 @@ class BackgroundSparseMed(Background):
292
329
  # Fill up remainder of index array with last entry
293
330
  bg_idx[idx1:] = ii
294
331
 
332
+ self.image_proc.value = 1
333
+
295
334
  # Write background data
296
335
  pos = 0
297
336
  step = 1000
298
- while pos < self.event_count:
299
- stop = min(pos + step, self.event_count)
337
+ while pos < self.image_count:
338
+ stop = min(pos + step, self.image_count)
300
339
  cur_slice = slice(pos, stop)
301
- self.h5out["events/image_bg"][cur_slice] = \
302
- bg_images[bg_idx[cur_slice]]
340
+ cur_bg_data = bg_images[bg_idx[cur_slice]]
341
+ self.writer.store_feature_chunk("image_bg", cur_bg_data)
342
+ if self.offset_correction:
343
+ # Record background offset correction "bg_off". We take a
344
+ # slice of 20px from the top of the image (there are normally
345
+ # no events here, only the channel walls are visible).
346
+ sh, sw = self.input_data.shape[1:]
347
+ roi_full = (slice(None), slice(0, 20), slice(0, sw))
348
+ roi_cur = (cur_slice, slice(0, 20), slice(0, sw))
349
+ val_bg = np.mean(cur_bg_data[roi_full], axis=(1, 2))
350
+ val_dat = np.mean(self.input_data[roi_cur], axis=(1, 2))
351
+ # background image = image_bg + bg_off
352
+ self.writer.store_feature_chunk("bg_off", val_dat - val_bg)
303
353
  pos += step
304
354
 
305
- def process_second(self, ii, second):
355
+ def process_second(self,
356
+ ii: int,
357
+ second: float | int):
306
358
  idx_start = np.argmin(np.abs(second - self.time))
307
359
  idx_stop = idx_start + self.kernel_size
308
- if idx_stop >= self.event_count:
309
- # If `idx_stop` is always greater than `self.event_count`,
310
- # then `diff` is always greater than zero.
311
- diff = idx_stop - self.event_count
312
- idx_stop -= diff
313
- idx_start -= diff
314
- assert idx_start >= 0
360
+ if idx_stop >= self.image_count:
361
+ idx_stop = self.image_count
362
+ idx_start = max(0, idx_stop - self.kernel_size)
363
+ assert idx_stop - idx_start == self.kernel_size
315
364
 
316
365
  # The following is equivalent to, but faster than:
317
366
  # self.bg_images[ii] = np.median(self.input_data[idx_start:idx_stop],
@@ -344,12 +393,14 @@ class BackgroundSparseMed(Background):
344
393
 
345
394
  self.bg_images[ii] = self.shared_output.reshape(self.image_shape)
346
395
 
396
+ self.image_proc.value = idx_stop / self.image_count
397
+
347
398
 
348
- class MedianWorkerSingle(mp_spawn.Process):
399
+ class WorkerSparseMed(mp_spawn.Process):
349
400
  def __init__(self, job_queue, counter, shared_input, shared_output,
350
401
  kernel_size, *args, **kwargs):
351
402
  """Worker process for median computation"""
352
- super(MedianWorkerSingle, self).__init__(*args, **kwargs)
403
+ super(WorkerSparseMed, self).__init__(*args, **kwargs)
353
404
  self.queue = job_queue
354
405
  self.queue.cancel_join_thread()
355
406
  self.counter = counter
@@ -1,3 +1,4 @@
1
1
  # flake8: noqa: F401
2
+ """Feature computation: brightness-based features"""
2
3
  from .bright_all import brightness_features
3
4
  from .common import brightness_names
@@ -4,16 +4,38 @@ import numpy as np
4
4
  from .common import brightness_names
5
5
 
6
6
 
7
- def brightness_features(image,
8
- mask,
9
- image_bg=None,
10
- image_corr=None):
7
+ def brightness_features(image: np.ndarray[np.uint8],
8
+ mask: np.ndarray[np.bool_],
9
+ image_bg: np.ndarray[np.uint8] = None,
10
+ image_corr: np.ndarray[np.int16] = None,
11
+ bg_off: float = None,
12
+ ):
13
+ """Compute brightness features
14
+
15
+ Parameters
16
+ ----------
17
+ image: np.ndarray
18
+ 2D array of "image" of shape (H, W)
19
+ mask: np.ndarray
20
+ 3D array containing the N masks of shape (N, H, W)
21
+ image_bg: np.ndarray
22
+ 2D array of "image_bg" of shape (H, W), required for computing
23
+ the "bg_med" feature.
24
+ image_corr: np.ndarray
25
+ 2D array of (image - image_bg), which can be optionally passed
26
+ to this method. If not given, will be computed.
27
+ bg_off: float
28
+ Systematic offset value for correcting the brightness of the
29
+ background data which has an effect on "bright_bc_avg",
30
+ "bright_perc_10", "bright_perc_90", and "bg_med" (`bg_off` is
31
+ generated by sparsemed background correction).
32
+ """
11
33
  mask = np.array(mask, dtype=bool)
12
34
  size = mask.shape[0]
13
35
 
14
36
  br_dict = {}
15
- for key in brightness_names:
16
- br_dict[key] = np.full(size, np.nan, dtype=np.float64)
37
+ for mkey in brightness_names:
38
+ br_dict[mkey] = np.full(size, np.nan, dtype=np.float64)
17
39
 
18
40
  avg_sd = compute_avg_sd_masked_uint8(image, mask)
19
41
  br_dict["bright_avg"][:] = avg_sd[:, 0]
@@ -36,6 +58,19 @@ def brightness_features(image,
36
58
  br_dict["bright_perc_10"][:] = percentiles[:, 0]
37
59
  br_dict["bright_perc_90"][:] = percentiles[:, 1]
38
60
 
61
+ if bg_off is not None:
62
+ # subtract the background offset for all values that are computed
63
+ # from background-corrected images
64
+ for mkey in ["bright_bc_avg", "bright_perc_10", "bright_perc_90"]:
65
+ if mkey in br_dict:
66
+ br_dict[mkey] -= bg_off
67
+
68
+ # add the background offset to all values that were computed from
69
+ # the background only
70
+ for pkey in ["bg_med"]:
71
+ if pkey in br_dict:
72
+ br_dict[pkey] += bg_off
73
+
39
74
  return br_dict
40
75
 
41
76
 
@@ -0,0 +1,4 @@
1
+ # flake8: noqa: F401
2
+ """Feature computation: OpenCV moments-based features"""
3
+ from .moments import moments_based_features
4
+ from .volume import volume_from_contours
@@ -2,11 +2,27 @@ import cv2
2
2
  import numpy as np
3
3
 
4
4
 
5
- from .ct_opencv import contour_single_opencv
6
-
7
-
8
- def moments_based_features(mask, pixel_size):
5
+ from .contour import contour_single_opencv
6
+
7
+
8
+ def moments_based_features(
9
+ mask: np.ndarray,
10
+ pixel_size: float,
11
+ ret_contour: bool = False,
12
+ ):
13
+ """Compute moment-based features for a mask image
14
+
15
+ Parameters
16
+ ----------
17
+ mask: np.ndarray
18
+ 3D stack of 2D boolean mask images to analyze
19
+ pixel_size: float
20
+ pixel size of the mask image in µm
21
+ ret_contour: bool
22
+ whether to also return the raw contour
23
+ """
9
24
  assert pixel_size is not None and pixel_size != 0
25
+ raw_contours = []
10
26
 
11
27
  size = mask.shape[0]
12
28
 
@@ -42,9 +58,13 @@ def moments_based_features(mask, pixel_size):
42
58
  for ii in range(size):
43
59
  # raw contour
44
60
  cont_raw = contour_single_opencv(mask[ii])
45
- if len(cont_raw.shape) < 2:
46
- continue
47
- if cv2.contourArea(cont_raw) == 0:
61
+ # only continue if the contour is valid
62
+ not_valid = len(cont_raw.shape) < 2 or cv2.contourArea(cont_raw) == 0
63
+
64
+ if ret_contour:
65
+ raw_contours.append(None if not_valid else cont_raw)
66
+
67
+ if not_valid:
48
68
  continue
49
69
 
50
70
  mu_raw = cv2.moments(cont_raw)
@@ -53,6 +73,7 @@ def moments_based_features(mask, pixel_size):
53
73
 
54
74
  # convex hull
55
75
  cont_cvx = np.squeeze(cv2.convexHull(cont_raw))
76
+
56
77
  mu_cvx = cv2.moments(cont_cvx)
57
78
  arc_cvx = np.float64(cv2.arcLength(cont_cvx, True))
58
79
 
@@ -110,7 +131,7 @@ def moments_based_features(mask, pixel_size):
110
131
  # specify validity
111
132
  valid[ii] = True
112
133
 
113
- return {
134
+ data = {
114
135
  "area_msd": feat_area_msd,
115
136
  "area_ratio": feat_area_ratio,
116
137
  "area_um": feat_area_um,
@@ -131,3 +152,6 @@ def moments_based_features(mask, pixel_size):
131
152
  "tilt": feat_tilt,
132
153
  "valid": valid,
133
154
  }
155
+ if ret_contour:
156
+ data["contour"] = raw_contours
157
+ return data
@@ -0,0 +1,174 @@
1
+ from typing import List
2
+
3
+ import numpy as np
4
+
5
+
6
+ def volume_from_contours(
7
+ contour: List[np.ndarray],
8
+ pos_x: np.ndarray,
9
+ pos_y: np.ndarray,
10
+ pixel_size: float):
11
+ """Calculate the volume of a polygon revolved around an axis
12
+
13
+ The volume estimation assumes rotational symmetry.
14
+
15
+ Parameters
16
+ ----------
17
+ contour: list of ndarrays of shape (N,2)
18
+ One entry is a 2D array that holds the contour of an event
19
+ pos_x: float ndarray of length N
20
+ The x coordinate(s) of the centroid of the event(s) [µm]
21
+ pos_y: float ndarray of length N
22
+ The y coordinate(s) of the centroid of the event(s) [µm]
23
+ pixel_size: float
24
+ The detector pixel size in µm.
25
+
26
+ Returns
27
+ -------
28
+ volume: float ndarray
29
+ volume in um^3
30
+
31
+ Notes
32
+ -----
33
+ The computation of the volume is based on a full rotation of the
34
+ upper and the lower halves of the contour from which the
35
+ average is then used.
36
+
37
+ The volume is computed radially from the center position
38
+ given by (`pos_x`, `pos_y`). For sufficiently smooth contours,
39
+ such as densely sampled ellipses, the center position does not
40
+ play an important role. For contours that are given on a coarse
41
+ grid, as is the case for deformability cytometry, the center position
42
+ must be given.
43
+
44
+ References
45
+ ----------
46
+ - https://de.wikipedia.org/wiki/Kegelstumpf#Formeln
47
+ - Yields identical results to the Matlab script by Geoff Olynyk
48
+ <https://de.mathworks.com/matlabcentral/fileexchange/36525-volrevolve>`_
49
+ """
50
+ # results are stored in a separate array initialized with nans
51
+ v_avg = np.zeros_like(pos_x, dtype=np.float64) * np.nan
52
+
53
+ for ii in range(pos_x.shape[0]):
54
+ # If the contour has less than 4 pixels, the computation will fail.
55
+ # In that case, the value np.nan is already assigned.
56
+ cc = contour[ii]
57
+ if cc is not None and cc.shape[0] >= 4:
58
+ # Center contour coordinates with given centroid
59
+ contour_x = cc[:, 0] - pos_x[ii] / pixel_size
60
+ contour_y = cc[:, 1] - pos_y[ii] / pixel_size
61
+ # Switch to r and z to follow notation of vol_revolve
62
+ # (In RT-DC the axis of rotation is x, but for vol_revolve
63
+ # we need the axis vertically)
64
+ contour_r = contour_y
65
+ contour_z = contour_x
66
+
67
+ # Compute right volume
68
+ # Which points are at negative r-values (r<0)?
69
+ inx_neg = np.where(contour_r < 0)
70
+ # These points will be shifted up to r=0 directly on the z-axis
71
+ contour_right = np.copy(contour_r)
72
+ contour_right[inx_neg] = 0
73
+ vol_right = vol_revolve(r=contour_right,
74
+ z=contour_z,
75
+ point_scale=pixel_size)
76
+
77
+ # Compute left volume
78
+ # Which points are at positive r-values? (r>0)?
79
+ idx_pos = np.where(contour_r > 0)
80
+ # These points will be shifted down to y=0 to build an x-axis
81
+ contour_left = np.copy(contour_r)
82
+ contour_left[idx_pos] = 0
83
+ # Now we still have negative r values, but vol_revolve needs
84
+ # positive values, so we flip the sign...
85
+ contour_left[:] *= -1
86
+ # ... but in doing so, we have switched to clockwise rotation,
87
+ # and we need to pass the array in reverse order
88
+ vol_left = vol_revolve(r=contour_left[::-1],
89
+ z=contour_z[::-1],
90
+ point_scale=pixel_size)
91
+
92
+ # Compute the average
93
+ v_avg[ii] = (vol_right + vol_left) / 2
94
+
95
+ return {"volume": v_avg}
96
+
97
+
98
+ def vol_revolve(r, z, point_scale=1.):
99
+ r"""Calculate the volume of a polygon revolved around the Z-axis
100
+
101
+ This implementation yields the same results as the volRevolve
102
+ Matlab function by Geoff Olynyk (from 2012-05-03)
103
+ https://de.mathworks.com/matlabcentral/fileexchange/36525-volrevolve.
104
+
105
+ The difference here is that the volume is computed using (a much
106
+ more approachable) implementation using the volume of a truncated
107
+ cone (https://de.wikipedia.org/wiki/Kegelstumpf).
108
+
109
+ .. math::
110
+
111
+ V = \frac{h \cdot \pi}{3} \cdot (R^2 + R \cdot r + r^2)
112
+
113
+ Where :math:`h` is the height of the cone and :math:`r` and
114
+ `R` are the smaller and larger radii of the truncated cone.
115
+
116
+ Each line segment of the contour resembles one truncated cone. If
117
+ the z-step is positive (counter-clockwise contour), then the
118
+ truncated cone volume is added to the total volume. If the z-step
119
+ is negative (e.g. inclusion), then the truncated cone volume is
120
+ removed from the total volume.
121
+
122
+ Parameters
123
+ ----------
124
+ r: 1d np.ndarray
125
+ radial coordinates (perpendicular to the z axis)
126
+ z: 1d np.ndarray
127
+ coordinate along the axis of rotation
128
+ point_scale: float
129
+ point size in your preferred units; The volume is multiplied
130
+ by a factor of `point_scale**3`.
131
+
132
+ Notes
133
+ -----
134
+ The coordinates must be given in counter-clockwise order,
135
+ otherwise the volume will be negative.
136
+ """
137
+ r = np.atleast_1d(r)
138
+ z = np.atleast_1d(z)
139
+
140
+ # make sure we have a closed contour
141
+ if (r[-1] != r[0]) or (z[-1] != z[0]):
142
+ # We have an open contour - close it.
143
+ r = np.resize(r, len(r) + 1)
144
+ z = np.resize(z, len(z) + 1)
145
+
146
+ rp = r[:-1]
147
+
148
+ # array of radii differences: R - r
149
+ dr = np.diff(r)
150
+ # array of height differences: h
151
+ dz = np.diff(z)
152
+
153
+ # If we expand the function in the doc string with
154
+ # dr = R - r and dz = h, then we get three terms for the volume
155
+ # (as opposed to four terms in Olynyk's script). Those three terms
156
+ # all resemble area slices multiplied by the z-distance dz.
157
+ a1 = 3 * rp ** 2
158
+ a2 = 3 * rp * dr
159
+ a3 = dr ** 2
160
+
161
+ # Note that the formula for computing the volume is symmetric
162
+ # with respect to r and R. This means that it does not matter
163
+ # which sign dr has (R and r are always positive). Since this
164
+ # algorithm assumes that the contour is ordered counter-clockwise,
165
+ # positive dz means adding to the contour while negative dz means
166
+ # subtracting from the contour (see test functions for more details).
167
+ # Conveniently so, dz only appears one time in this formula, so
168
+ # we can take the sign of dz as it is (Otherwise, we would have
169
+ # to take the absolute value of every truncated cone volume and
170
+ # multiply it by np.sign(dz)).
171
+ v = np.pi / 3 * dz * np.abs(a1 + a2 + a3)
172
+ vol = np.sum(v) * point_scale ** 3
173
+
174
+ return vol
@@ -1,2 +1,3 @@
1
1
  # flake8: noqa: F401
2
+ """Feature computation: Haralick texture features"""
2
3
  from .tex_all import haralick_names, haralick_texture_features
@@ -6,6 +6,34 @@ from .common import haralick_names
6
6
 
7
7
  def haralick_texture_features(
8
8
  mask, image=None, image_bg=None, image_corr=None):
9
+ """Compute Haralick texture features
10
+
11
+ The following texture features are excluded
12
+
13
+ - feature 6 "Sum Average", which is equivalent to `2 * bright_bc_avg`
14
+ since dclab 0.44.0
15
+ - feature 10 "Difference Variance", because it has a functional
16
+ dependency on the offset value and since we do background correction,
17
+ we are not interested in it
18
+ - feature 14, because nobody is using it, it is not understood by
19
+ everyone what it actually is, and it is computationally expensive.
20
+
21
+ This leaves us with the following 11 texture features (22 if you count
22
+ avg and ptp):
23
+ https://earlglynn.github.io/RNotes/package/EBImage/Haralick-Textural-Features.html
24
+
25
+ - 1. `tex_asm`: (1) Angular Second Moment
26
+ - 2. `tex_con`: (2) Contrast
27
+ - 3. `tex_cor`: (3) Correlation
28
+ - 4. `tex_var`: (4) Variance
29
+ - 5. `tex_idm`: (5) Inverse Difference Moment
30
+ - 6. `tex_sva`: (7) Sum Variance
31
+ - 7. `tex_sen`: (8) Sum Entropy
32
+ - 8. `tex_ent`: (9) Entropy
33
+ - 9. `tex_den`: (11) Difference Entropy
34
+ - 10. `tex_f12`: (12) Information Measure of Correlation 1
35
+ - 11. `tex_f13`: (13) Information Measure of Correlation 2
36
+ """
9
37
  # make sure we have a boolean array
10
38
  mask = np.array(mask, dtype=bool)
11
39
  size = mask.shape[0]
@@ -22,7 +50,6 @@ def haralick_texture_features(
22
50
 
23
51
  for ii in range(size):
24
52
  # Haralick texture features
25
- # https://gitlab.gwdg.de/blood_data_analysis/dcevent/-/issues/20
26
53
  # Preprocessing:
27
54
  # - create a copy of the array (don't edit `image_corr`)
28
55
  # - add grayscale values (negative values not supported)