dcnum 0.17.0__py3-none-any.whl → 0.23.2__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 (49) hide show
  1. dcnum/_version.py +2 -2
  2. dcnum/feat/__init__.py +1 -1
  3. dcnum/feat/event_extractor_manager_thread.py +34 -25
  4. dcnum/feat/feat_background/base.py +22 -26
  5. dcnum/feat/feat_background/bg_copy.py +18 -12
  6. dcnum/feat/feat_background/bg_roll_median.py +20 -10
  7. dcnum/feat/feat_background/bg_sparse_median.py +55 -7
  8. dcnum/feat/feat_brightness/bright_all.py +41 -6
  9. dcnum/feat/feat_contour/__init__.py +4 -0
  10. dcnum/feat/{feat_moments/mt_legacy.py → feat_contour/moments.py} +32 -8
  11. dcnum/feat/feat_contour/volume.py +174 -0
  12. dcnum/feat/feat_texture/tex_all.py +28 -1
  13. dcnum/feat/gate.py +2 -2
  14. dcnum/feat/queue_event_extractor.py +30 -9
  15. dcnum/logic/ctrl.py +222 -48
  16. dcnum/logic/job.py +85 -2
  17. dcnum/logic/json_encoder.py +2 -0
  18. dcnum/meta/ppid.py +17 -3
  19. dcnum/read/__init__.py +1 -0
  20. dcnum/read/cache.py +100 -78
  21. dcnum/read/const.py +6 -4
  22. dcnum/read/hdf5_data.py +146 -23
  23. dcnum/read/mapped.py +87 -0
  24. dcnum/segm/__init__.py +6 -3
  25. dcnum/segm/segm_thresh.py +6 -18
  26. dcnum/segm/segm_torch/__init__.py +23 -0
  27. dcnum/segm/segm_torch/segm_torch_base.py +125 -0
  28. dcnum/segm/segm_torch/segm_torch_mpo.py +71 -0
  29. dcnum/segm/segm_torch/segm_torch_sto.py +88 -0
  30. dcnum/segm/segm_torch/torch_model.py +95 -0
  31. dcnum/segm/segm_torch/torch_postproc.py +93 -0
  32. dcnum/segm/segm_torch/torch_preproc.py +114 -0
  33. dcnum/segm/segmenter.py +181 -80
  34. dcnum/segm/segmenter_manager_thread.py +38 -30
  35. dcnum/segm/{segmenter_cpu.py → segmenter_mpo.py} +116 -44
  36. dcnum/segm/segmenter_sto.py +110 -0
  37. dcnum/write/__init__.py +2 -1
  38. dcnum/write/deque_writer_thread.py +9 -1
  39. dcnum/write/queue_collector_thread.py +8 -14
  40. dcnum/write/writer.py +128 -5
  41. {dcnum-0.17.0.dist-info → dcnum-0.23.2.dist-info}/METADATA +4 -2
  42. dcnum-0.23.2.dist-info/RECORD +55 -0
  43. {dcnum-0.17.0.dist-info → dcnum-0.23.2.dist-info}/WHEEL +1 -1
  44. dcnum/feat/feat_moments/__init__.py +0 -4
  45. dcnum/segm/segmenter_gpu.py +0 -64
  46. dcnum-0.17.0.dist-info/RECORD +0 -46
  47. /dcnum/feat/{feat_moments/ct_opencv.py → feat_contour/contour.py} +0 -0
  48. {dcnum-0.17.0.dist-info → dcnum-0.23.2.dist-info}/LICENSE +0 -0
  49. {dcnum-0.17.0.dist-info → dcnum-0.23.2.dist-info}/top_level.txt +0 -0
dcnum/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.17.0'
16
- __version_tuple__ = version_tuple = (0, 17, 0)
15
+ __version__ = version = '0.23.2'
16
+ __version_tuple__ = version_tuple = (0, 23, 2)
dcnum/feat/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  # flake8: noqa: F401
2
2
  """Feature computation"""
3
- from . import feat_background, feat_brightness, feat_moments, feat_texture
3
+ from . import feat_background, feat_brightness, feat_contour, feat_texture
4
4
  from .event_extractor_manager_thread import EventExtractorManagerThread
5
5
  from .queue_event_extractor import (
6
6
  QueueEventExtractor, EventExtractorThread, EventExtractorProcess
@@ -46,8 +46,8 @@ class EventExtractorManagerThread(threading.Thread):
46
46
  The queue the writer uses. We monitor this queue. If it
47
47
  fills up, we take a break.
48
48
  debug:
49
- Whether to run in debugging mode which means more log
50
- messages and only one thread (`num_workers` has no effect).
49
+ Whether to run in debugging mode which means only one
50
+ event extraction thread (`num_workers` has no effect).
51
51
  """
52
52
  super(EventExtractorManagerThread, self).__init__(
53
53
  name="EventExtractorManager", *args, **kwargs)
@@ -95,32 +95,41 @@ class EventExtractorManagerThread(threading.Thread):
95
95
  while True:
96
96
  # If the writer_dq starts filling up, then this could lead to
97
97
  # an oom-kill signal. Stall for the writer to prevent this.
98
- ldq = len(self.writer_dq)
99
- if ldq > 100:
100
- stallsec = ldq / 100
98
+ if (ldq := len(self.writer_dq)) > 1000:
99
+ time.sleep(1)
100
+ ldq2 = len(self.writer_dq)
101
+ stall_time = max(0., (ldq2 - 200) / ((ldq - ldq2) or 1))
102
+ time.sleep(stall_time)
101
103
  self.logger.warning(
102
- f"Stalling {stallsec:.1f}s for slow writer")
103
- time.sleep(stallsec)
104
+ f"Stalled {stall_time + 1:.1f}s for slow writer "
105
+ f"({ldq} chunks queued)")
104
106
 
105
- cur_slot = 0
106
107
  unavailable_slots = 0
108
+ found_free_slot = False
107
109
  # Check all slots for segmented labels
108
- while True:
109
- # - "e" there is data from the segmenter (the extractor
110
- # can take it and process it)
111
- # - "s" the extractor processed the data and is waiting
112
- # for the segmenter
113
- if self.slot_states[cur_slot] == "e":
114
- # The segmenter has something for us in this slot.
115
- break
116
- else:
117
- # Try another slot.
118
- unavailable_slots += 1
119
- cur_slot = (cur_slot + 1) % num_slots
120
- if unavailable_slots >= num_slots:
121
- # There is nothing to do, try to avoid 100% CPU
122
- unavailable_slots = 0
123
- time.sleep(.1)
110
+ while not found_free_slot:
111
+ # We sort the slots according to the slot chunks so that we
112
+ # always process the slot with the smallest slot chunk number
113
+ # first. Initially, the slot_chunks array is filled with
114
+ # zeros, but the segmenter fills up the slots with the lowest
115
+ # number first.
116
+ for cur_slot in np.argsort(self.slot_chunks):
117
+ # - "e" there is data from the segmenter (the extractor
118
+ # can take it and process it)
119
+ # - "s" the extractor processed the data and is waiting
120
+ # for the segmenter
121
+ if self.slot_states[cur_slot] == "e":
122
+ # The segmenter has something for us in this slot.
123
+ found_free_slot = True
124
+ break
125
+ else:
126
+ # Try another slot.
127
+ unavailable_slots += 1
128
+ cur_slot = (cur_slot + 1) % num_slots
129
+ if unavailable_slots >= num_slots:
130
+ # There is nothing to do, try to avoid 100% CPU
131
+ unavailable_slots = 0
132
+ time.sleep(.1)
124
133
 
125
134
  t1 = time.monotonic()
126
135
 
@@ -147,7 +156,7 @@ class EventExtractorManagerThread(threading.Thread):
147
156
  # We are done here. The segmenter may continue its deed.
148
157
  self.slot_states[cur_slot] = "w"
149
158
 
150
- self.logger.debug(f"Extracted one chunk: {chunk}")
159
+ self.logger.debug(f"Extracted chunk {chunk} in slot {cur_slot}")
151
160
  self.t_count += time.monotonic() - t1
152
161
 
153
162
  chunks_processed += 1
@@ -3,14 +3,12 @@ import functools
3
3
  import inspect
4
4
  import multiprocessing as mp
5
5
  import pathlib
6
- import uuid
7
6
 
8
7
  import h5py
9
- import numpy as np
10
8
 
11
9
  from ...meta import ppid
12
10
  from ...read import HDF5Data
13
- from ...write import create_with_basins, set_default_filter_kwargs
11
+ from ...write import HDF5Writer, create_with_basins, set_default_filter_kwargs
14
12
 
15
13
 
16
14
  # All subprocesses should use 'spawn' to avoid issues with threads
@@ -64,8 +62,8 @@ class Background(abc.ABC):
64
62
 
65
63
  #: number of images in the input data
66
64
  self.image_count = None
67
- #: number of images that have been processed
68
- self.image_proc = mp_spawn.Value("L", 0)
65
+ #: fraction images that have been processed
66
+ self.image_proc = mp_spawn.Value("d", 0)
69
67
 
70
68
  #: HDF5Data instance for input data
71
69
  self.hdin = None
@@ -93,8 +91,6 @@ class Background(abc.ABC):
93
91
  else:
94
92
  self.input_data = input_data
95
93
 
96
- #: unique identifier
97
- self.name = str(uuid.uuid4())
98
94
  #: shape of event images
99
95
  self.image_shape = self.input_data[0].shape
100
96
  #: total number of events
@@ -109,25 +105,17 @@ class Background(abc.ABC):
109
105
  # "a", because output file already exists
110
106
  self.h5out = h5py.File(output_path, "a", libver="latest")
111
107
 
112
- # Initialize background data
113
- ds_kwargs = set_default_filter_kwargs(compression=compress)
114
- h5bg = self.h5out.require_dataset(
115
- "events/image_bg",
116
- shape=self.input_data.shape,
117
- dtype=np.uint8,
118
- chunks=(min(100, self.image_count),
119
- self.image_shape[0],
120
- self.image_shape[1]),
121
- **ds_kwargs,
108
+ # Initialize writer
109
+ self.writer = HDF5Writer(
110
+ obj=self.h5out,
111
+ ds_kwds=set_default_filter_kwargs(compression=compress),
122
112
  )
123
- h5bg.attrs.create('CLASS', np.string_('IMAGE'))
124
- h5bg.attrs.create('IMAGE_VERSION', np.string_('1.2'))
125
- h5bg.attrs.create('IMAGE_SUBCLASS', np.string_('IMAGE_GRAYSCALE'))
126
113
 
127
114
  def __enter__(self):
128
115
  return self
129
116
 
130
117
  def __exit__(self, type, value, tb):
118
+ self.writer.close()
131
119
  # Close h5in and h5out
132
120
  if self.hdin is not None: # we have an input file
133
121
  self.hdin.close() # this closes self.h5in
@@ -142,7 +130,7 @@ class Background(abc.ABC):
142
130
  """Return a unique background pipeline identifier
143
131
 
144
132
  The pipeline identifier is universally applicable and must
145
- be backwards-compatible (future versions of dcevent will
133
+ be backwards-compatible (future versions of dcnum will
146
134
  correctly acknowledge the ID).
147
135
 
148
136
  The segmenter pipeline ID is defined as::
@@ -197,15 +185,23 @@ class Background(abc.ABC):
197
185
  if self.image_count == 0:
198
186
  return 0.
199
187
  else:
200
- return self.image_proc.value / self.image_count
188
+ return self.image_proc.value
201
189
 
202
190
  def process(self):
191
+ # Delete any old background data
192
+ for key in ["image_bg", "bg_off"]:
193
+ if key in self.h5out["events"]:
194
+ del self.h5out["events"][key]
195
+ # Perform the actual background computation
203
196
  self.process_approach()
204
197
  bg_ppid = self.get_ppid()
205
- # Store pipeline information in the image_bg feature
206
- self.h5out["events/image_bg"].attrs["dcnum ppid background"] = bg_ppid
207
- self.h5out["events/image_bg"].attrs["dcnum ppid generation"] = \
208
- ppid.DCNUM_PPID_GENERATION
198
+ # Store pipeline information in the image_bg/bg_off feature
199
+ for key in ["image_bg", "bg_off"]:
200
+ if key in self.h5out["events"]:
201
+ self.h5out[f"events/{key}"].attrs["dcnum ppid background"] = \
202
+ bg_ppid
203
+ self.h5out[F"events/{key}"].attrs["dcnum ppid generation"] = \
204
+ ppid.DCNUM_PPID_GENERATION
209
205
 
210
206
  @abc.abstractmethod
211
207
  def process_approach(self):
@@ -4,22 +4,28 @@ from .base import Background
4
4
 
5
5
 
6
6
  class BackgroundCopy(Background):
7
-
8
7
  @staticmethod
9
8
  def check_user_kwargs():
10
9
  pass
11
10
 
12
- def process_approach(self):
13
- """Perform median computation on entire input data"""
11
+ def process(self):
12
+ """Copy input data to output dataset"""
14
13
  if self.h5in != self.h5out:
15
- hin = self.hdin.image_bg.h5ds
16
- if "image_bg" in self.h5out["events"]:
17
- del self.h5out["events/image_bg"]
18
- h5py.h5o.copy(src_loc=hin.parent.id,
19
- src_name=b"image_bg",
20
- dst_loc=self.h5out["events"].id,
21
- dst_name=b"image_bg",
22
- )
14
+ hin = self.hdin.h5
15
+ for feat in ["image_bg", "bg_off"]:
16
+ if feat in hin["events"]:
17
+ h5py.h5o.copy(src_loc=hin["events"].id,
18
+ src_name=feat.encode("utf-8"),
19
+ dst_loc=self.h5out["events"].id,
20
+ dst_name=feat.encode("utf-8"),
21
+ )
23
22
 
24
23
  # set progress to 100%
25
- self.image_proc.value = self.image_count
24
+ self.image_proc.value = 1
25
+
26
+ def process_approach(self):
27
+ # We do the copying in `process`, because we do not want to modify
28
+ # any metadata or delete datasets as is done in the base class.
29
+ # But we still have to implement this method, because it is an
30
+ # abstractmethod in the base class.
31
+ pass
@@ -119,7 +119,7 @@ class BackgroundRollMed(Background):
119
119
  """Check user-defined properties of this class
120
120
 
121
121
  This method primarily exists so that the CLI knows which
122
- keyword arguements can be passed to this class.
122
+ keyword arguments can be passed to this class.
123
123
 
124
124
  Parameters
125
125
  ----------
@@ -132,7 +132,8 @@ class BackgroundRollMed(Background):
132
132
  `kernel_size` will not increase computation speed. Larger
133
133
  values lead to a higher memory consumption.
134
134
  """
135
- assert kernel_size > 0
135
+ assert kernel_size > 0, "kernel size must be positive number"
136
+ assert kernel_size % 2 == 0, "kernel size must be even number"
136
137
  assert batch_size > kernel_size
137
138
 
138
139
  def get_slices_for_batch(self, batch_index=0):
@@ -172,11 +173,18 @@ class BackgroundRollMed(Background):
172
173
  num_steps = int(np.ceil(self.image_count / self.batch_size))
173
174
  for ii in range(num_steps):
174
175
  self.process_next_batch()
175
- # Set the remaining kernel_size median values to the last one
176
- last_image = self.h5out["events/image_bg"][-self.kernel_size-1]
177
- for ii in range(self.kernel_size):
178
- self.h5out["events/image_bg"][self.image_count-ii-1] = last_image
179
- self.image_proc.value = self.image_count
176
+
177
+ # Set the remaining median bg images to the last one.
178
+ num_remaining = (self.input_data.shape[0]
179
+ - self.h5out["events/image_bg"].shape[0])
180
+ if num_remaining:
181
+ last_image = self.h5out["events/image_bg"][-1]
182
+ last_chunk = np.repeat(
183
+ last_image[np.newaxis],
184
+ num_remaining,
185
+ axis=0)
186
+ self.writer.store_feature_chunk("image_bg", last_chunk)
187
+ self.image_proc.value = 1
180
188
 
181
189
  def process_next_batch(self):
182
190
  """Process one batch of input data"""
@@ -208,12 +216,14 @@ class BackgroundRollMed(Background):
208
216
  # TODO:
209
217
  # Do this in a different thread so workers can keep going
210
218
  # and use a lock somewhere in case the disk is too slow.
211
- self.h5out["events/image_bg"][cur_slice_out] = \
219
+ self.writer.store_feature_chunk(
220
+ "image_bg",
212
221
  self.shared_output[:output_size].reshape(output_size,
213
- *self.image_shape)
222
+ *self.image_shape),
223
+ )
214
224
 
215
225
  self.current_batch += 1
216
- self.image_proc.value += self.batch_size
226
+ self.image_proc.value += self.batch_size / self.image_count
217
227
 
218
228
 
219
229
  class WorkerRollMed(mp_spawn.Process):
@@ -15,6 +15,7 @@ logger = logging.getLogger(__name__)
15
15
  class BackgroundSparseMed(Background):
16
16
  def __init__(self, input_data, output_path, kernel_size=200,
17
17
  split_time=1., thresh_cleansing=0, frac_cleansing=.8,
18
+ offset_correction=True,
18
19
  compress=True, num_cpus=None):
19
20
  """Sparse median background correction with cleansing
20
21
 
@@ -57,6 +58,21 @@ class BackgroundSparseMed(Background):
57
58
  Fraction between 0 and 1 indicating how many background images
58
59
  must still be present after cleansing (in case the cleansing
59
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.
60
76
  compress: bool
61
77
  Whether to compress background data. Set this to False
62
78
  for faster processing.
@@ -72,7 +88,9 @@ class BackgroundSparseMed(Background):
72
88
  kernel_size=kernel_size,
73
89
  split_time=split_time,
74
90
  thresh_cleansing=thresh_cleansing,
75
- frac_cleansing=frac_cleansing)
91
+ frac_cleansing=frac_cleansing,
92
+ offset_correction=offset_correction,
93
+ )
76
94
 
77
95
  if kernel_size > len(self.input_data):
78
96
  logger.warning(
@@ -88,6 +106,8 @@ class BackgroundSparseMed(Background):
88
106
  self.thresh_cleansing = thresh_cleansing
89
107
  #: keep at least this many background images from the series
90
108
  self.frac_cleansing = frac_cleansing
109
+ #: offset/flickering correction
110
+ self.offset_correction = offset_correction
91
111
 
92
112
  # time axis
93
113
  self.time = None
@@ -175,11 +195,13 @@ class BackgroundSparseMed(Background):
175
195
  kernel_size: int = 200,
176
196
  split_time: float = 1.,
177
197
  thresh_cleansing: float = 0,
178
- frac_cleansing: float = .8):
198
+ frac_cleansing: float = .8,
199
+ offset_correction: bool = True,
200
+ ):
179
201
  """Initialize user-defined properties of this class
180
202
 
181
203
  This method primarily exists so that the CLI knows which
182
- keyword arguements can be passed to this class.
204
+ keyword arguments can be passed to this class.
183
205
 
184
206
  Parameters
185
207
  ----------
@@ -197,6 +219,21 @@ class BackgroundSparseMed(Background):
197
219
  Fraction between 0 and 1 indicating how many background images
198
220
  must still be present after cleansing (in case the cleansing
199
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.
200
237
  """
201
238
  assert kernel_size > 0
202
239
  assert split_time > 0
@@ -292,7 +329,7 @@ class BackgroundSparseMed(Background):
292
329
  # Fill up remainder of index array with last entry
293
330
  bg_idx[idx1:] = ii
294
331
 
295
- self.image_proc.value = self.image_count
332
+ self.image_proc.value = 1
296
333
 
297
334
  # Write background data
298
335
  pos = 0
@@ -300,8 +337,19 @@ class BackgroundSparseMed(Background):
300
337
  while pos < self.image_count:
301
338
  stop = min(pos + step, self.image_count)
302
339
  cur_slice = slice(pos, stop)
303
- self.h5out["events/image_bg"][cur_slice] = \
304
- 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)
305
353
  pos += step
306
354
 
307
355
  def process_second(self,
@@ -345,7 +393,7 @@ class BackgroundSparseMed(Background):
345
393
 
346
394
  self.bg_images[ii] = self.shared_output.reshape(self.image_shape)
347
395
 
348
- self.image_proc.value = idx_stop
396
+ self.image_proc.value = idx_stop / self.image_count
349
397
 
350
398
 
351
399
  class WorkerSparseMed(mp_spawn.Process):
@@ -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