cellects 0.2.7__py3-none-any.whl → 0.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.
@@ -1,1067 +1,1068 @@
1
- """Module for computing shape descriptors from binary images.
2
-
3
- This module provides a framework for calculating various geometric and statistical
4
- descriptors of shapes in binary images through configurable dictionaries and a core class.
5
- Supported metrics include area, perimeter, axis lengths, orientation, and more.
6
- Descriptor computation is controlled via category dictionaries (e.g., `descriptors_categories`)
7
- and implemented as methods in the ShapeDescriptors class.
8
-
9
- Classes
10
- -------
11
- ShapeDescriptors : Class to compute various descriptors for a binary image
12
-
13
- Notes
14
- -----
15
- Relies on OpenCV and NumPy for image processing operations.
16
- Shape descriptors: The following names, lists and computes all the variables describing a shape in a binary image.
17
- If you want to allow the software to compute another variable:
18
- 1) In the following dicts and list, you need to:
19
- add the variable name and whether to compute it (True/False) by default
20
- 2) In the ShapeDescriptors class:
21
- add a method to compute that variable
22
- 3) In the init method of the ShapeDescriptors class
23
- attribute a None value to the variable that store it
24
- add a if condition in the for loop to compute that variable when its name appear in the wanted_descriptors_list
25
- """
26
- import cv2
27
- import numpy as np
28
- from typing import Tuple
29
- from numpy.typing import NDArray
30
- from copy import deepcopy
31
- import pandas as pd
32
- from cellects.utils.utilitarian import translate_dict, smallest_memory_array
33
- from cellects.utils.formulas import (get_inertia_axes, get_standard_deviations, get_skewness, get_kurtosis,
34
- get_newly_explored_area)
35
-
36
- descriptors_categories = {'area': True, 'perimeter': False, 'circularity': False, 'rectangularity': False,
37
- 'total_hole_area': False, 'solidity': False, 'convexity': False, 'eccentricity': False,
38
- 'euler_number': False, 'standard_deviation_xy': False, 'skewness_xy': False,
39
- 'kurtosis_xy': False, 'major_axes_len_and_angle': True, 'iso_digi_analysis': False,
40
- 'oscilacyto_analysis': False,
41
- 'fractal_analysis': False
42
- }
43
-
44
- descriptors_names_to_display = ['Area', 'Perimeter', 'Circularity', 'Rectangularity', 'Total hole area',
45
- 'Solidity', 'Convexity', 'Eccentricity', 'Euler number', 'Standard deviation xy',
46
- 'Skewness xy', 'Kurtosis xy', 'Major axes lengths and angle',
47
- 'Growth transitions', 'Oscillations',
48
- 'Minkowski dimension'
49
- ]
50
-
51
- from_shape_descriptors_class = {'area': True, 'perimeter': False, 'circularity': False, 'rectangularity': False,
52
- 'total_hole_area': False, 'solidity': False, 'convexity': False, 'eccentricity': False,
53
- 'euler_number': False, 'standard_deviation_y': False, 'standard_deviation_x': False,
54
- 'skewness_y': False, 'skewness_x': False, 'kurtosis_y': False, 'kurtosis_x': False,
55
- 'major_axis_len': True, 'minor_axis_len': True, 'axes_orientation': True
56
- }
57
-
58
- length_descriptors = ['perimeter', 'major_axis_len', 'minor_axis_len']
59
- area_descriptors = ['area', 'area_total', 'total_hole_area', 'newly_explored_area', 'final_area']
60
-
61
- descriptors = deepcopy(from_shape_descriptors_class)
62
- descriptors.update({'minkowski_dimension': False})
63
-
64
- def compute_one_descriptor_per_frame(binary_vid: NDArray[np.uint8], arena_label: int, timings: NDArray,
65
- descriptors_dict: dict, output_in_mm: bool, pixel_size: float,
66
- do_fading: bool, save_coord_specimen:bool):
67
- """
68
- Computes descriptors for each frame in a binary video and returns them as a DataFrame.
69
-
70
- Parameters
71
- ----------
72
- binary_vid : NDArray[np.uint8]
73
- The binary video data where each frame is a 2D array.
74
- arena_label : int
75
- Label for the arena in the video.
76
- timings : NDArray
77
- Array of timestamps corresponding to each frame.
78
- descriptors_dict : dict
79
- Dictionary containing the descriptors to be computed.
80
- output_in_mm : bool, optional
81
- Flag indicating if output should be in millimeters. Default is False.
82
- pixel_size : float, optional
83
- Size of a pixel in the video when `output_in_mm` is True. Default is None.
84
- do_fading : bool, optional
85
- Flag indicating if the fading effect should be applied. Default is False.
86
- save_coord_specimen : bool, optional
87
- Flag indicating if the coordinates of specimens should be saved. Default is False.
88
-
89
- Returns
90
- -------
91
- pandas.DataFrame
92
- DataFrame containing the descriptors for each frame in the video.
93
-
94
- Notes
95
- -----
96
- For large inputs, consider pre-allocating memory for efficiency.
97
- The `save_coord_specimen` flag will save coordinate data to a file.
98
-
99
- Examples
100
- --------
101
- >>> binary_vid = np.ones((10, 640, 480), dtype=np.uint8)
102
- >>> timings = np.arange(10)
103
- >>> descriptors_dict = {'area': True, 'perimeter': True}
104
- >>> result = compute_one_descriptor_per_frame(binary_vid, 1, timings, descriptors_dict)
105
- >>> print(result.head())
106
- arena time area perimeter
107
- 0 1 0 0 0
108
- 1 1 1 0 0
109
- 2 1 2 0 0
110
- 3 1 3 0 0
111
- 4 1 4 0 0
112
-
113
- >>> binary_vid = np.ones((5, 640, 480), dtype=np.uint8)
114
- >>> timings = np.arange(5)
115
- >>> descriptors_dict = {'area': True, 'perimeter': True}
116
- >>> result = compute_one_descriptor_per_frame(binary_vid, 2, timings,
117
- ... descriptors_dict,
118
- ... output_in_mm=True,
119
- ... pixel_size=0.1)
120
- >>> print(result.head())
121
- arena time area perimeter
122
- 0 2 0 0 0.0
123
- 1 2 1 0 0.0
124
- 2 2 2 0 0.0
125
- 3 2 3 0 0.0
126
- 4 2 4 0 0.0
127
- """
128
- dims = binary_vid.shape
129
- all_descriptors, to_compute_from_sd, length_measures, area_measures = initialize_descriptor_computation(descriptors_dict)
130
- one_row_per_frame = pd.DataFrame(np.zeros((dims[0], 2 + len(all_descriptors))),
131
- columns=['arena', 'time'] + all_descriptors)
132
- one_row_per_frame['arena'] = [arena_label] * dims[0]
133
- one_row_per_frame['time'] = timings
134
- for t in np.arange(dims[0]):
135
- SD = ShapeDescriptors(binary_vid[t, :, :], to_compute_from_sd)
136
- for descriptor in to_compute_from_sd:
137
- one_row_per_frame.loc[t, descriptor] = SD.descriptors[descriptor]
138
- if save_coord_specimen:
139
- np.save(f"coord_specimen{arena_label}_t{dims[0]}_y{dims[1]}_x{dims[2]}.npy",
140
- smallest_memory_array(np.nonzero(binary_vid), "uint"))
141
- # Adjust descriptors scale if output_in_mm is specified
142
- if do_fading:
143
- one_row_per_frame['newly_explored_area'] = get_newly_explored_area(binary_vid)
144
- if output_in_mm:
145
- one_row_per_frame = scale_descriptors(one_row_per_frame, pixel_size,
146
- length_measures, area_measures)
147
- return one_row_per_frame
148
-
149
-
150
- def compute_one_descriptor_per_colony(binary_vid: NDArray[np.uint8], arena_label: int, timings: NDArray,
151
- descriptors_dict: dict, output_in_mm: bool, pixel_size: float,
152
- do_fading: bool, min_colony_size: int, save_coord_specimen: bool):
153
- dims = binary_vid.shape
154
- all_descriptors, to_compute_from_sd, length_measures, area_measures = initialize_descriptor_computation(
155
- descriptors_dict)
156
- # Objective: create a matrix with 4 columns (time, y, x, colony) containing the coordinates of all colonies
157
- # against time
158
- max_colonies = 0
159
- for t in np.arange(dims[0]):
160
- nb, shapes = cv2.connectedComponents(binary_vid[t, :, :])
161
- max_colonies = np.max((max_colonies, nb))
162
-
163
- time_descriptor_colony = np.zeros((dims[0], len(to_compute_from_sd) * max_colonies * dims[0]),
164
- dtype=np.float32) # Adjust max_colonies
165
- colony_number = 0
166
- colony_id_matrix = np.zeros(dims[1:], dtype=np.uint64)
167
- coord_colonies = []
168
- centroids = []
169
-
170
- # pat_tracker = PercentAndTimeTracker(dims[0], compute_with_elements_number=True)
171
- for t in np.arange(dims[0]):
172
- # We rank colonies in increasing order to make sure that the larger colony issued from a colony division
173
- # keeps the previous colony name.
174
- # shapes, stats, centers = cc(binary_vid[t, :, :])
175
- nb, shapes, stats, centers = cv2.connectedComponentsWithStats(binary_vid[t, :, :])
176
- true_colonies = np.nonzero(stats[:, 4] >= min_colony_size)[1:]
177
- # Consider that shapes bellow 3 pixels are noise. The loop will stop at nb and not compute them
178
-
179
- # current_percentage, eta = pat_tracker.get_progress(t, element_number=nb)
180
- # logging.info(f"Arena n°{arena_label}, Colony descriptors computation: {current_percentage}%{eta}")
181
-
182
- updated_colony_names = np.zeros(1, dtype=np.uint32)
183
- for colon_i in true_colonies: # 120)):# #92
184
- current_colony_img = shapes == colon_i
185
- if current_colony_img.sum() >= 4:
186
- current_colony_img = current_colony_img.astype(np.uint8)
187
-
188
- # I/ Find out which names the current colony had at t-1
189
- colony_previous_names = np.unique(current_colony_img * colony_id_matrix)
190
- colony_previous_names = colony_previous_names[colony_previous_names != 0]
191
- # II/ Find out if the current colony name had already been analyzed at t
192
- # If there no match with the saved colony_id_matrix, assign colony ID
193
- if t == 0 or len(colony_previous_names) == 0:
194
- # logging.info("New colony")
195
- colony_number += 1
196
- colony_names = [colony_number]
197
- # If there is at least 1 match with the saved colony_id_matrix, we keep the colony_previous_name(s)
198
- else:
199
- colony_names = colony_previous_names.tolist()
200
- # Handle colony division if necessary
201
- if np.any(np.isin(updated_colony_names, colony_names)):
202
- colony_number += 1
203
- colony_names = [colony_number]
204
-
205
- # Update colony ID matrix for the current frame
206
- coords = np.nonzero(current_colony_img)
207
- colony_id_matrix[coords[0], coords[1]] = colony_names[0]
208
-
209
- # Add coordinates to coord_colonies
210
- time_column = np.full(coords[0].shape, t, dtype=np.uint32)
211
- colony_column = np.full(coords[0].shape, colony_names[0], dtype=np.uint32)
212
- coord_colonies.append(np.column_stack((time_column, colony_column, coords[0], coords[1])))
213
-
214
- # Calculate centroid and add to centroids list
215
- centroid_x, centroid_y = centers[colon_i, :]
216
- centroids.append((t, colony_names[0], centroid_y, centroid_x))
217
-
218
- # Compute shape descriptors
219
- SD = ShapeDescriptors(current_colony_img, to_compute_from_sd)
220
- # descriptors = list(SD.descriptors.values())
221
- descriptors = SD.descriptors
222
- # Adjust descriptors if output_in_mm is specified
223
- if output_in_mm:
224
- descriptors = scale_descriptors(descriptors, pixel_size, length_measures, area_measures)
225
- # Store descriptors in time_descriptor_colony
226
- descriptor_index = (colony_names[0] - 1) * len(to_compute_from_sd)
227
- time_descriptor_colony[t, descriptor_index:(descriptor_index + len(descriptors))] = list(
228
- descriptors.values())
229
-
230
- updated_colony_names = np.append(updated_colony_names, colony_names)
231
-
232
- # Reset colony_id_matrix for the next frame
233
- colony_id_matrix *= binary_vid[t, :, :]
234
- if len(centroids) > 0:
235
- centroids = np.array(centroids, dtype=np.float32)
236
- else:
237
- centroids = np.zeros((0, 4), dtype=np.float32)
238
- time_descriptor_colony = time_descriptor_colony[:, :(colony_number * len(to_compute_from_sd))]
239
- if len(coord_colonies) > 0:
240
- coord_colonies = np.vstack(coord_colonies)
241
- if save_coord_specimen:
242
- coord_colonies = pd.DataFrame(coord_colonies, columns=["time", "colony", "y", "x"])
243
- coord_colonies.to_csv(
244
- f"coord_colonies{arena_label}_t{dims[0]}_col{colony_number}_y{dims[1]}_x{dims[2]}.csv",
245
- sep=';', index=False, lineterminator='\n')
246
-
247
- centroids = pd.DataFrame(centroids, columns=["time", "colony", "y", "x"])
248
- centroids.to_csv(
249
- f"colony_centroids{arena_label}_t{dims[0]}_col{colony_number}_y{dims[1]}_x{dims[2]}.csv",
250
- sep=';', index=False, lineterminator='\n')
251
-
252
- # Format the final dataframe to have one row per time frame, and one column per descriptor_colony_name
253
- one_row_per_frame = pd.DataFrame({'arena': arena_label, 'time': timings,
254
- 'area_total': binary_vid.sum((1, 2)).astype(np.float64)})
255
-
256
- if do_fading:
257
- one_row_per_frame['newly_explored_area'] = get_newly_explored_area(binary_vid)
258
- if output_in_mm:
259
- one_row_per_frame = scale_descriptors(one_row_per_frame, pixel_size)
260
-
261
- column_names = np.char.add(np.repeat(to_compute_from_sd, colony_number),
262
- np.tile((np.arange(colony_number) + 1).astype(str), len(to_compute_from_sd)))
263
- time_descriptor_colony = pd.DataFrame(time_descriptor_colony, columns=column_names)
264
- one_row_per_frame = pd.concat([one_row_per_frame, time_descriptor_colony], axis=1)
265
-
266
- return one_row_per_frame
267
-
268
- def initialize_descriptor_computation(descriptors_dict: dict) -> Tuple[list, list, list, list]:
269
- """
270
-
271
- Initialize descriptor computation based on available and requested descriptors.
272
-
273
- Parameters
274
- ----------
275
- descriptors_dict : dict
276
- A dictionary where keys are descriptor names and values are booleans indicating whether
277
- to compute the corresponding descriptor.
278
-
279
- Returns
280
- -------
281
- tuple
282
- A tuple containing four lists:
283
- - all_descriptors: List of all requested descriptor names.
284
- - to_compute_from_sd: Array of descriptor names that need to be computed from the shape descriptors class.
285
- - length_measures: Array of descriptor names that are length measures and need to be computed.
286
- - area_measures: Array of descriptor names that are area measures and need to be computed.
287
-
288
- Examples
289
- --------
290
- >>> descriptors_dict = {'perimeter': True, 'area': False}
291
- >>> all_descriptors, to_compute_from_sd, length_measures, area_measures = initialize_descriptor_computation(descriptors_dict)
292
- >>> print(all_descriptors, to_compute_from_sd, length_measures, area_measures)
293
- ['length'] ['length'] ['length'] []
294
-
295
- """
296
- available_descriptors_in_sd = list(from_shape_descriptors_class.keys())
297
- all_descriptors = []
298
- to_compute_from_sd = []
299
- for name, do_compute in descriptors_dict.items():
300
- if do_compute:
301
- all_descriptors.append(name)
302
- if np.isin(name, available_descriptors_in_sd):
303
- to_compute_from_sd.append(name)
304
- to_compute_from_sd = np.array(to_compute_from_sd)
305
- length_measures = to_compute_from_sd[np.isin(to_compute_from_sd, length_descriptors)]
306
- area_measures = to_compute_from_sd[np.isin(to_compute_from_sd, area_descriptors)]
307
-
308
- return all_descriptors, to_compute_from_sd, length_measures, area_measures
309
-
310
- def scale_descriptors(descriptors_dict, pixel_size: float, length_measures: NDArray[str]=None, area_measures: NDArray[str]=None):
311
- """
312
- Scale the spatial descriptors in a dictionary based on pixel size.
313
-
314
- Parameters
315
- ----------
316
- descriptors_dict : dict
317
- Dictionary containing spatial descriptors.
318
- pixel_size : float
319
- Pixel size used for scaling.
320
- length_measures : numpy.ndarray, optional
321
- Array of descriptors that represent lengths. If not provided,
322
- they will be initialized.
323
- area_measures : numpy.ndarray, optional
324
- Array of descriptors that represent areas. If not provided,
325
- they will be initialized.
326
-
327
- Returns
328
- -------
329
- dict
330
- Dictionary with scaled spatial descriptors.
331
-
332
- Examples
333
- --------
334
- >>> from numpy import array as ndarray
335
- >>> descriptors_dict = {'length': ndarray([1, 2]), 'area': ndarray([3, 4])}
336
- >>> pixel_size = 0.5
337
- >>> scaled_dict = scale_descriptors(descriptors_dict, pixel_size)
338
- >>> print(scaled_dict)
339
- {'length': array([0.5, 1.]), 'area': array([1.58421369, 2.])}
340
- """
341
- if length_measures is None or area_measures is None:
342
- to_compute_from_sd = np.array(list(descriptors_dict.keys()))
343
- length_measures = to_compute_from_sd[np.isin(to_compute_from_sd, length_descriptors)]
344
- area_measures = to_compute_from_sd[np.isin(to_compute_from_sd, area_descriptors)]
345
- for descr in length_measures:
346
- descriptors_dict[descr] *= pixel_size
347
- for descr in area_measures:
348
- descriptors_dict[descr] *= np.sqrt(pixel_size)
349
- return descriptors_dict
350
-
351
-
352
- class ShapeDescriptors:
353
- """
354
- This class takes :
355
- - a binary image of 0 and 1 drawing one shape
356
- - a list of descriptors to calculate from that image
357
- ["area", "perimeter", "circularity", "rectangularity", "total_hole_area", "solidity", "convexity",
358
- "eccentricity", "euler_number",
359
-
360
- "standard_deviation_y", "standard_deviation_x", "skewness_y", "skewness_x", "kurtosis_y", "kurtosis_x",
361
- "major_axis_len", "minor_axis_len", "axes_orientation",
362
-
363
- "mo", "contours", "min_bounding_rectangle", "convex_hull"]
364
-
365
- Be careful! mo, contours, min_bounding_rectangle, convex_hull,
366
- standard_deviations, skewness and kurtosis are not atomics
367
- https://www.researchgate.net/publication/27343879_Estimators_for_Orientation_and_Anisotropy_in_Digitized_Images
368
- """
369
-
370
- def __init__(self, binary_image, wanted_descriptors_list):
371
- """
372
- Class to compute various descriptors for a binary image.
373
-
374
- Parameters
375
- ----------
376
- binary_image : ndarray
377
- Binary image used to compute the descriptors.
378
- wanted_descriptors_list : list
379
- List of strings with the names of the wanted descriptors.
380
-
381
- Attributes
382
- ----------
383
- binary_image : ndarray
384
- The binary image.
385
- descriptors : dict
386
- Dictionary containing the computed descriptors.
387
- mo : float or None, optional
388
- Moment of inertia (default is `None`).
389
- area : int or None, optional
390
- Area of the object (default is `None`).
391
- contours : ndarray or None, optional
392
- Contours of the object (default is `None`).
393
- min_bounding_rectangle : tuple or None, optional
394
- Minimum bounding rectangle of the object (default is `None`).
395
- convex_hull : ndarray or None, optional
396
- Convex hull of the object (default is `None`).
397
- major_axis_len : float or None, optional
398
- Major axis length of the object (default is `None`).
399
- minor_axis_len : float or None, optional
400
- Minor axis length of the object (default is `None`).
401
- axes_orientation : float or None, optional
402
- Orientation of the axes (default is `None`).
403
- sx : float or None, optional
404
- Standard deviation in x-axis (default is `None`).
405
- kx : float or None, optional
406
- Kurtosis in x-axis (default is `None`).
407
- skx : float or None, optional
408
- Skewness in x-axis (default is `None`).
409
- perimeter : float or None, optional
410
- Perimeter of the object (default is `None`).
411
- convexity : float or None, optional
412
- Convexity of the object (default is `None`).
413
-
414
- Examples
415
- --------
416
- >>> binary_image = np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8)
417
- >>> wanted_descriptors_list = ["area", "perimeter"]
418
- >>> SD = ShapeDescriptors(binary_image, wanted_descriptors_list)
419
- >>> SD.descriptors
420
- {'area': np.uint64(9), 'perimeter': 8.0}
421
- """
422
- # Give a None value to each parameters whose presence is assessed before calculation (less calculus for speed)
423
- self.mo = None
424
- self.area = None
425
- self.contours = None
426
- self.min_bounding_rectangle = None
427
- self.convex_hull = None
428
- self.major_axis_len = None
429
- self.minor_axis_len = None
430
- self.axes_orientation = None
431
- self.sx = None
432
- self.kx = None
433
- self.skx = None
434
- self.perimeter = None
435
- self.convexity = None
436
-
437
- self.binary_image = binary_image
438
- if self.binary_image.dtype == 'bool':
439
- self.binary_image = self.binary_image.astype(np.uint8)
440
-
441
- self.descriptors = {i: np.empty(0, dtype=np.float64) for i in wanted_descriptors_list}
442
- self.get_area()
443
-
444
- for name in self.descriptors.keys():
445
- if name == "mo":
446
- self.get_mo()
447
- self.descriptors[name] = self.mo
448
- elif name == "area":
449
- self.descriptors[name] = self.area
450
- elif name == "contours":
451
- self.get_contours()
452
- self.descriptors[name] = self.contours
453
- elif name == "min_bounding_rectangle":
454
- self.get_min_bounding_rectangle()
455
- self.descriptors[name] = self.min_bounding_rectangle
456
- elif name == "major_axis_len":
457
- self.get_major_axis_len()
458
- self.descriptors[name] = self.major_axis_len
459
- elif name == "minor_axis_len":
460
- self.get_minor_axis_len()
461
- self.descriptors[name] = self.minor_axis_len
462
- elif name == "axes_orientation":
463
- self.get_inertia_axes()
464
- self.descriptors[name] = self.axes_orientation
465
- elif name == "standard_deviation_y":
466
- self.get_standard_deviations()
467
- self.descriptors[name] = self.sy
468
- elif name == "standard_deviation_x":
469
- self.get_standard_deviations()
470
- self.descriptors[name] = self.sx
471
- elif name == "skewness_y":
472
- self.get_skewness()
473
- self.descriptors[name] = self.sky
474
- elif name == "skewness_x":
475
- self.get_skewness()
476
- self.descriptors[name] = self.skx
477
- elif name == "kurtosis_y":
478
- self.get_kurtosis()
479
- self.descriptors[name] = self.ky
480
- elif name == "kurtosis_x":
481
- self.get_kurtosis()
482
- self.descriptors[name] = self.kx
483
- elif name == "convex_hull":
484
- self.get_convex_hull()
485
- self.descriptors[name] = self.convex_hull
486
- elif name == "perimeter":
487
- self.get_perimeter()
488
- self.descriptors[name] = self.perimeter
489
- elif name == "circularity":
490
- self.get_circularity()
491
- self.descriptors[name] = self.circularity
492
- elif name == "rectangularity":
493
- self.get_rectangularity()
494
- self.descriptors[name] = self.rectangularity
495
- elif name == "total_hole_area":
496
- self.get_total_hole_area()
497
- self.descriptors[name] = self.total_hole_area
498
- elif name == "solidity":
499
- self.get_solidity()
500
- self.descriptors[name] = self.solidity
501
- elif name == "convexity":
502
- self.get_convexity()
503
- self.descriptors[name] = self.convexity
504
- elif name == "eccentricity":
505
- self.get_eccentricity()
506
- self.descriptors[name] = self.eccentricity
507
- elif name == "euler_number":
508
- self.get_euler_number()
509
- self.descriptors[name] = self.euler_number
510
-
511
- """
512
- The following methods can be called to compute parameters for descriptors requiring it
513
- """
514
-
515
- def get_mo(self):
516
- """
517
- Get moments of a binary image.
518
-
519
- Calculate the image moments for a given binary image using OpenCV's
520
- `cv2.moments` function and then translate these moments into a formatted
521
- dictionary.
522
-
523
- Notes
524
- -----
525
- This function assumes the binary image has already been processed and is in a
526
- suitable format for moment calculation.
527
-
528
- Returns
529
- -------
530
- None
531
-
532
- Examples
533
- --------
534
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["mo"])
535
- >>> print(SD.mo["m00"])
536
- 9.0
537
- """
538
- self.mo = translate_dict(cv2.moments(self.binary_image))
539
-
540
- def get_area(self):
541
- """
542
- Calculate the area of a binary image by summing its pixel values.
543
-
544
- This function computes the area covered by white pixels (value 1) in a binary image,
545
- which is equivalent to counting the number of 'on' pixels.
546
-
547
- Notes
548
- -----
549
- Sums values in `self.binary_image` and stores the result in `self.area`.
550
-
551
- Returns
552
- -------
553
- None
554
-
555
- Examples
556
- --------
557
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["area"])
558
- >>> print(SD.area)
559
- 9.0
560
- """
561
- self.area = self.binary_image.sum()
562
-
563
- def get_contours(self):
564
- """
565
- Find and process the largest contour in a binary image.
566
-
567
- Retrieves contours from a binary image, calculates the Euler number,
568
- and identifies the largest contour based on its length.
569
-
570
- Notes
571
- -----
572
- This function modifies the internal state of the `self` object to store
573
- the largest contour and Euler number.
574
-
575
- Returns
576
- -------
577
- None
578
-
579
- Examples
580
- --------
581
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["euler_number"])
582
- >>> print(len(SD.contours))
583
- 8
584
- """
585
- if self.area == 0:
586
- self.euler_number = 0.
587
- self.contours = np.array([], np.uint8)
588
- else:
589
- contours, hierarchy = cv2.findContours(self.binary_image, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
590
- nb, shapes = cv2.connectedComponents(self.binary_image, ltype=cv2.CV_16U)
591
- self.euler_number = (nb - 1) - len(contours)
592
- self.contours = contours[0]
593
- if len(contours) > 1:
594
- all_lengths = np.zeros(len(contours))
595
- for i, contour in enumerate(contours):
596
- all_lengths[i] = len(contour)
597
- self.contours = contours[np.argmax(all_lengths)]
598
-
599
- def get_min_bounding_rectangle(self):
600
- """
601
- Retrieve the minimum bounding rectangle from the contours of an image.
602
-
603
- This method calculates the smallest area rectangle that can enclose
604
- the object outlines present in the image, which is useful for
605
- object detection and analysis tasks.
606
-
607
- Notes
608
- -----
609
- - The bounding rectangle is calculated only if contours are available.
610
- If not, they will be retrieved first before calculating the rectangle.
611
-
612
- Raises
613
- ------
614
- RuntimeError
615
- If the contours are not available and cannot be retrieved,
616
- indicating a problem with the image or preprocessing steps.
617
-
618
- Returns
619
- -------
620
- None
621
-
622
- Examples
623
- --------
624
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["rectangularity"])
625
- >>> print(len(SD.min_bounding_rectangle))
626
- 3
627
- """
628
- if self.area == 0:
629
- self.min_bounding_rectangle = np.array([], np.uint8)
630
- else:
631
- if self.contours is None:
632
- self.get_contours()
633
- if len(self.contours) == 0:
634
- self.min_bounding_rectangle = np.array([], np.uint8)
635
- else:
636
- self.min_bounding_rectangle = cv2.minAreaRect(self.contours) # ((cx, cy), (width, height), angle)
637
-
638
- def get_inertia_axes(self):
639
- """
640
- Calculate and set the moments of inertia properties of an object.
641
-
642
- This function computes the centroid, major axis length,
643
- minor axis length, and axes orientation for an object. It
644
- first ensures that the moments of inertia (`mo`) attribute is available,
645
- computing them if necessary, before using the `get_inertia_axes` function.
646
-
647
- Returns
648
- -------
649
- None
650
-
651
- This method sets the following attributes:
652
- - `cx` : float
653
- The x-coordinate of the centroid.
654
- - `cy` : float
655
- The y-coordinate of the centroid.
656
- - `major_axis_len` : float
657
- The length of the major axis.
658
- - `minor_axis_len` : float
659
- The length of the minor axis.
660
- - `axes_orientation` : float
661
- The orientation angle of the axes.
662
-
663
- Raises
664
- ------
665
- ValueError
666
- If there is an issue with the moments of inertia computation.
667
-
668
- Notes
669
- -----
670
- This function modifies in-place the object's attributes related to its geometry.
671
-
672
- Examples
673
- --------
674
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["major_axis_len"])
675
- >>> print(SD.axes_orientation)
676
- 0.0
677
- """
678
- if self.mo is None:
679
- self.get_mo()
680
- if self.area == 0:
681
- self.cx, self.cy, self.major_axis_len, self.minor_axis_len, self.axes_orientation = 0, 0, 0, 0, 0
682
- else:
683
- self.cx, self.cy, self.major_axis_len, self.minor_axis_len, self.axes_orientation = get_inertia_axes(self.mo)
684
-
685
- def get_standard_deviations(self):
686
- """
687
- Calculate and store standard deviations along x and y (sx, sy).
688
-
689
- Notes
690
- -----
691
- Requires centroid and moments; values are stored in `self.sx` and `self.sy`.
692
-
693
- Returns
694
- -------
695
- None
696
-
697
- Examples
698
- --------
699
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["standard_deviation_x", "standard_deviation_y"])
700
- >>> print(SD.sx, SD.sy)
701
- 0.816496580927726 0.816496580927726
702
- """
703
- if self.sx is None:
704
- if self.axes_orientation is None:
705
- self.get_inertia_axes()
706
- self.sx, self.sy = get_standard_deviations(self.mo, self.binary_image, self.cx, self.cy)
707
-
708
- def get_skewness(self):
709
- """
710
- Calculate and store skewness along x and y (skx, sky).
711
-
712
- This function computes the skewness about the x-axis and y-axis of
713
- an image. Skewness is a measure of the asymmetry of the probability
714
- distribution of values in an image.
715
-
716
- Notes
717
- -----
718
- Requires standard deviations; values are stored in `self.skx` and `self.sky`.
719
-
720
- Returns
721
- -------
722
- None
723
-
724
- Examples
725
- --------
726
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["skewness_x", "skewness_y"])
727
- >>> print(SD.skx, SD.sky)
728
- 0.0 0.0
729
- """
730
- if self.skx is None:
731
- if self.sx is None:
732
- self.get_standard_deviations()
733
-
734
- self.skx, self.sky = get_skewness(self.mo, self.binary_image, self.cx, self.cy, self.sx, self.sy)
735
-
736
- def get_kurtosis(self):
737
- """
738
- Calculates the kurtosis of the image moments.
739
-
740
- Kurtosis is a statistical measure that describes the shape of
741
- a distribution's tails in relation to its overall shape. It is
742
- used here in the context of image moments analysis.
743
-
744
- Notes
745
- -----
746
- This function first checks if the kurtosis values have already been calculated.
747
- If not, it calculates them using the `get_kurtosis` function.
748
-
749
- Examples
750
- --------
751
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["kurtosis_x", "kurtosis_y"])
752
- >>> print(SD.kx, SD.ky)
753
- 1.5 1.5
754
- """
755
- if self.kx is None:
756
- if self.sx is None:
757
- self.get_standard_deviations()
758
-
759
- self.kx, self.ky = get_kurtosis(self.mo, self.binary_image, self.cx, self.cy, self.sx, self.sy)
760
-
761
- def get_convex_hull(self):
762
- """
763
- Compute and store the convex hull of the object's contour.
764
-
765
- Notes
766
- -----
767
- Stores the result in `self.convex_hull`. Computes contours if needed.
768
-
769
- Returns
770
- -------
771
- None
772
-
773
- Examples
774
- --------
775
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["solidity"])
776
- >>> print(len(SD.convex_hull))
777
- 4
778
- """
779
- if self.area == 0:
780
- self.convex_hull = np.array([], np.uint8)
781
- else:
782
- if self.contours is None:
783
- self.get_contours()
784
- self.convex_hull = cv2.convexHull(self.contours)
785
-
786
- def get_perimeter(self):
787
- """
788
- Compute and store the contour perimeter length.
789
-
790
- Notes
791
- -----
792
- Computes contours if needed and stores the length in `self.perimeter`.
793
-
794
- Returns
795
- -------
796
- None
797
-
798
- Examples
799
- --------
800
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["perimeter"])
801
- >>> print(SD.perimeter)
802
- 8.0
803
- """
804
- if self.area == 0:
805
- self.perimeter = 0.
806
- else:
807
- if self.contours is None:
808
- self.get_contours()
809
- if len(self.contours) == 0:
810
- self.perimeter = 0.
811
- else:
812
- self.perimeter = cv2.arcLength(self.contours, True)
813
-
814
- def get_circularity(self):
815
- """
816
- Compute and store circularity: 4πA / P².
817
-
818
- Notes
819
- -----
820
- Uses `self.area` and `self.perimeter`; stores result in `self.circularity`.
821
-
822
- Returns
823
- -------
824
- None
825
-
826
- Examples
827
- --------
828
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["circularity"])
829
- >>> print(SD.circularity)
830
- 1.7671458676442586
831
- """
832
- if self.area == 0:
833
- self.circularity = 0.
834
- else:
835
- if self.perimeter is None:
836
- self.get_perimeter()
837
- if self.perimeter == 0:
838
- self.circularity = 0.
839
- else:
840
- self.circularity = (4 * np.pi * self.binary_image.sum()) / np.square(self.perimeter)
841
-
842
- def get_rectangularity(self):
843
- """
844
- Compute and store rectangularity: area / bounding-rectangle-area.
845
-
846
- Notes
847
- -----
848
- Uses `self.binary_image` and `self.min_bounding_rectangle`. Computes the MBR if needed.
849
-
850
- Returns
851
- -------
852
- None
853
-
854
- Examples
855
- --------
856
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["rectangularity"])
857
- >>> print(SD.rectangularity)
858
- 2.25
859
- """
860
- if self.area == 0:
861
- self.rectangularity = 0.
862
- else:
863
- if self.min_bounding_rectangle is None:
864
- self.get_min_bounding_rectangle()
865
- bounding_rectangle_area = self.min_bounding_rectangle[1][0] * self.min_bounding_rectangle[1][1]
866
- if bounding_rectangle_area == 0:
867
- self.rectangularity = 0.
868
- else:
869
- self.rectangularity = self.binary_image.sum() / bounding_rectangle_area
870
-
871
- def get_total_hole_area(self):
872
- """
873
- Calculate the total area of holes in a binary image.
874
-
875
- This function uses connected component labeling to detect and
876
- measure the area of holes in a binary image.
877
-
878
- Returns
879
- -------
880
- float
881
- The total area of all detected holes in the binary image.
882
-
883
- Notes
884
- -----
885
- This function assumes that the binary image has been pre-processed
886
- and that holes are represented as connected components of zero
887
- pixels within the foreground
888
-
889
- Examples
890
- --------
891
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["total_hole_area"])
892
- >>> print(SD.total_hole_area)
893
- 0
894
- """
895
- nb, new_order = cv2.connectedComponents(1 - self.binary_image)
896
- if nb > 2:
897
- self.total_hole_area = (new_order > 1).sum()
898
- else:
899
- self.total_hole_area = 0.
900
-
901
- def get_solidity(self):
902
- """
903
- Compute and store solidity: contour area / convex hull area.
904
-
905
- Extended Summary
906
- ----------------
907
- The solidity is a dimensionless measure that compares the area of a shape to
908
- its convex hull. A solidity of 1 means the contour is fully convex, while a
909
- value less than 1 indicates concavities.
910
-
911
- Notes
912
- -----
913
- If the convex hull area is 0 or absent, solidity is set to 0.
914
-
915
- Returns
916
- -------
917
- None
918
-
919
- Examples
920
- --------
921
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["solidity"])
922
- >>> print(SD.solidity)
923
- 1.0
924
- """
925
- if self.area == 0:
926
- self.solidity = 0.
927
- else:
928
- if self.convex_hull is None:
929
- self.get_convex_hull()
930
- if len(self.convex_hull) == 0:
931
- self.solidity = 0.
932
- else:
933
- hull_area = cv2.contourArea(self.convex_hull)
934
- if hull_area == 0:
935
- self.solidity = 0.
936
- else:
937
- self.solidity = cv2.contourArea(self.contours) / hull_area
938
-
939
- def get_convexity(self):
940
- """
941
- Compute and store convexity: convex hull perimeter / contour perimeter.
942
-
943
- Notes
944
- -----
945
- Requires `self.perimeter` and `self.convex_hull`.
946
-
947
- Returns
948
- -------
949
- None
950
-
951
- Examples
952
- --------
953
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["convexity"])
954
- >>> print(SD.convexity)
955
- 1.0
956
- """
957
- if self.perimeter is None:
958
- self.get_perimeter()
959
- if self.convex_hull is None:
960
- self.get_convex_hull()
961
- if self.perimeter == 0 or len(self.convex_hull) == 0:
962
- self.convexity = 0.
963
- else:
964
- self.convexity = cv2.arcLength(self.convex_hull, True) / self.perimeter
965
-
966
- def get_eccentricity(self):
967
- """
968
- Compute and store eccentricity from major and minor axis lengths.
969
-
970
- Notes
971
- -----
972
- Calls `get_inertia_axes()` if needed and stores result in `self.eccentricity`.
973
-
974
- Returns
975
- -------
976
- None
977
-
978
- Examples
979
- --------
980
- >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["eccentricity"])
981
- >>> print(SD.eccentricity)
982
- 0.0
983
- """
984
- self.get_inertia_axes()
985
- if self.major_axis_len == 0:
986
- self.eccentricity = 0.
987
- else:
988
- self.eccentricity = np.sqrt(1 - np.square(self.minor_axis_len / self.major_axis_len))
989
-
990
- def get_euler_number(self):
991
- """
992
- Ensure contours are computed; stores Euler number in `self.euler_number` via `get_contours()`.
993
-
994
- Returns
995
- -------
996
- None
997
-
998
- Notes
999
- -----
1000
- Euler number is computed in `get_contours()` as `(components - 1) - len(contours)`.
1001
- """
1002
- if self.contours is None:
1003
- self.get_contours()
1004
-
1005
- def get_major_axis_len(self):
1006
- """
1007
- Ensure the major axis length is computed and stored in `self.major_axis_len`.
1008
-
1009
- Returns
1010
- -------
1011
- None
1012
-
1013
- Notes
1014
- -----
1015
- Triggers `get_inertia_axes()` if needed.
1016
-
1017
- Examples
1018
- --------
1019
- >>> SD = ShapeDescriptors(np.array([[0, 1, 0], [0, 1, 0], [0, 1, 0]], dtype=np.uint8), ["major_axis_len"])
1020
- >>> print(SD.major_axis_len)
1021
- 2.8284271247461907
1022
- """
1023
- if self.major_axis_len is None:
1024
- self.get_inertia_axes()
1025
-
1026
- def get_minor_axis_len(self):
1027
- """
1028
- Ensure the minor axis length is computed and stored in `self.minor_axis_len`.
1029
-
1030
- Returns
1031
- -------
1032
- None
1033
-
1034
- Notes
1035
- -----
1036
- Triggers `get_inertia_axes()` if needed.
1037
-
1038
- Examples
1039
- --------
1040
- >>> SD = ShapeDescriptors(np.array([[0, 1, 0], [0, 1, 0], [0, 1, 0]], dtype=np.uint8), ["minor_axis_len"])
1041
- >>> print(SD.minor_axis_len)
1042
- 0.0
1043
- """
1044
- if self.minor_axis_len is None:
1045
- self.get_inertia_axes()
1046
-
1047
- def get_axes_orientation(self):
1048
- """
1049
- Ensure the axes orientation angle is computed and stored in `self.axes_orientation`.
1050
-
1051
- Returns
1052
- -------
1053
- None
1054
-
1055
- Notes
1056
- -----
1057
- Calls `get_inertia_axes()` if orientation is not yet computed.
1058
-
1059
- Examples
1060
- --------
1061
- >>> SD = ShapeDescriptors(np.array([[0, 1, 0], [0, 1, 0], [0, 1, 0]], dtype=np.uint8), ["axes_orientation"])
1062
- >>> print(SD.axes_orientation)
1063
- 1.5707963267948966
1064
- """
1065
- if self.axes_orientation is None:
1066
- self.get_inertia_axes()
1067
-
1
+ """Module for computing shape descriptors from binary images.
2
+
3
+ This module provides a framework for calculating various geometric and statistical
4
+ descriptors of shapes in binary images through configurable dictionaries and a core class.
5
+ Supported metrics include area, perimeter, axis lengths, orientation, and more.
6
+ Descriptor computation is controlled via category dictionaries (e.g., `descriptors_categories`)
7
+ and implemented as methods in the ShapeDescriptors class.
8
+
9
+ Classes
10
+ -------
11
+ ShapeDescriptors : Class to compute various descriptors for a binary image
12
+
13
+ Notes
14
+ -----
15
+ Relies on OpenCV and NumPy for image processing operations.
16
+ Shape descriptors: The following names, lists and computes all the variables describing a shape in a binary image.
17
+ If you want to allow the software to compute another variable:
18
+ 1) In the following dicts and list, you need to:
19
+ add the variable name and whether to compute it (True/False) by default
20
+ 2) In the ShapeDescriptors class:
21
+ add a method to compute that variable
22
+ 3) In the init method of the ShapeDescriptors class
23
+ attribute a None value to the variable that store it
24
+ add a if condition in the for loop to compute that variable when its name appear in the wanted_descriptors_list
25
+ """
26
+ import cv2
27
+ import numpy as np
28
+ from typing import Tuple
29
+ from numpy.typing import NDArray
30
+ from copy import deepcopy
31
+ import pandas as pd
32
+ from tqdm import tqdm
33
+ from cellects.utils.utilitarian import translate_dict, smallest_memory_array
34
+ from cellects.utils.formulas import (get_inertia_axes, get_standard_deviations, get_skewness, get_kurtosis,
35
+ get_newly_explored_area)
36
+
37
+ descriptors_categories = {'area': True, 'perimeter': False, 'circularity': False, 'rectangularity': False,
38
+ 'total_hole_area': False, 'solidity': False, 'convexity': False, 'eccentricity': False,
39
+ 'euler_number': False, 'standard_deviation_xy': False, 'skewness_xy': False,
40
+ 'kurtosis_xy': False, 'major_axes_len_and_angle': True, 'iso_digi_analysis': False,
41
+ 'oscilacyto_analysis': False,
42
+ 'fractal_analysis': False
43
+ }
44
+
45
+ descriptors_names_to_display = ['Area', 'Perimeter', 'Circularity', 'Rectangularity', 'Total hole area',
46
+ 'Solidity', 'Convexity', 'Eccentricity', 'Euler number', 'Standard deviation xy',
47
+ 'Skewness xy', 'Kurtosis xy', 'Major axes lengths and angle',
48
+ 'Growth transitions', 'Oscillations',
49
+ 'Minkowski dimension'
50
+ ]
51
+
52
+ from_shape_descriptors_class = {'area': True, 'perimeter': False, 'circularity': False, 'rectangularity': False,
53
+ 'total_hole_area': False, 'solidity': False, 'convexity': False, 'eccentricity': False,
54
+ 'euler_number': False, 'standard_deviation_y': False, 'standard_deviation_x': False,
55
+ 'skewness_y': False, 'skewness_x': False, 'kurtosis_y': False, 'kurtosis_x': False,
56
+ 'major_axis_len': True, 'minor_axis_len': True, 'axes_orientation': True
57
+ }
58
+
59
+ length_descriptors = ['perimeter', 'major_axis_len', 'minor_axis_len']
60
+ area_descriptors = ['area', 'area_total', 'total_hole_area', 'newly_explored_area', 'final_area']
61
+
62
+ descriptors = deepcopy(from_shape_descriptors_class)
63
+ descriptors.update({'minkowski_dimension': False})
64
+
65
+ def compute_one_descriptor_per_frame(binary_vid: NDArray[np.uint8], arena_label: int, timings: NDArray,
66
+ descriptors_dict: dict, output_in_mm: bool, pixel_size: float,
67
+ do_fading: bool, save_coord_specimen:bool):
68
+ """
69
+ Computes descriptors for each frame in a binary video and returns them as a DataFrame.
70
+
71
+ Parameters
72
+ ----------
73
+ binary_vid : NDArray[np.uint8]
74
+ The binary video data where each frame is a 2D array.
75
+ arena_label : int
76
+ Label for the arena in the video.
77
+ timings : NDArray
78
+ Array of timestamps corresponding to each frame.
79
+ descriptors_dict : dict
80
+ Dictionary containing the descriptors to be computed.
81
+ output_in_mm : bool, optional
82
+ Flag indicating if output should be in millimeters. Default is False.
83
+ pixel_size : float, optional
84
+ Size of a pixel in the video when `output_in_mm` is True. Default is None.
85
+ do_fading : bool, optional
86
+ Flag indicating if the fading effect should be applied. Default is False.
87
+ save_coord_specimen : bool, optional
88
+ Flag indicating if the coordinates of specimens should be saved. Default is False.
89
+
90
+ Returns
91
+ -------
92
+ pandas.DataFrame
93
+ DataFrame containing the descriptors for each frame in the video.
94
+
95
+ Notes
96
+ -----
97
+ For large inputs, consider pre-allocating memory for efficiency.
98
+ The `save_coord_specimen` flag will save coordinate data to a file.
99
+
100
+ Examples
101
+ --------
102
+ >>> binary_vid = np.ones((10, 640, 480), dtype=np.uint8)
103
+ >>> timings = np.arange(10)
104
+ >>> descriptors_dict = {'area': True, 'perimeter': True}
105
+ >>> result = compute_one_descriptor_per_frame(binary_vid, 1, timings, descriptors_dict)
106
+ >>> print(result.head())
107
+ arena time area perimeter
108
+ 0 1 0 0 0
109
+ 1 1 1 0 0
110
+ 2 1 2 0 0
111
+ 3 1 3 0 0
112
+ 4 1 4 0 0
113
+
114
+ >>> binary_vid = np.ones((5, 640, 480), dtype=np.uint8)
115
+ >>> timings = np.arange(5)
116
+ >>> descriptors_dict = {'area': True, 'perimeter': True}
117
+ >>> result = compute_one_descriptor_per_frame(binary_vid, 2, timings,
118
+ ... descriptors_dict,
119
+ ... output_in_mm=True,
120
+ ... pixel_size=0.1)
121
+ >>> print(result.head())
122
+ arena time area perimeter
123
+ 0 2 0 0 0.0
124
+ 1 2 1 0 0.0
125
+ 2 2 2 0 0.0
126
+ 3 2 3 0 0.0
127
+ 4 2 4 0 0.0
128
+ """
129
+ dims = binary_vid.shape
130
+ all_descriptors, to_compute_from_sd, length_measures, area_measures = initialize_descriptor_computation(descriptors_dict)
131
+ one_row_per_frame = pd.DataFrame(np.zeros((dims[0], 2 + len(all_descriptors))),
132
+ columns=['arena', 'time'] + all_descriptors)
133
+ one_row_per_frame['arena'] = [arena_label] * dims[0]
134
+ one_row_per_frame['time'] = timings
135
+ for t in np.arange(dims[0]):
136
+ SD = ShapeDescriptors(binary_vid[t, :, :], to_compute_from_sd)
137
+ for descriptor in to_compute_from_sd:
138
+ one_row_per_frame.loc[t, descriptor] = SD.descriptors[descriptor]
139
+ if save_coord_specimen:
140
+ np.save(f"coord_specimen{arena_label}_t{dims[0]}_y{dims[1]}_x{dims[2]}.npy",
141
+ smallest_memory_array(np.nonzero(binary_vid), "uint"))
142
+ # Adjust descriptors scale if output_in_mm is specified
143
+ if do_fading:
144
+ one_row_per_frame['newly_explored_area'] = get_newly_explored_area(binary_vid)
145
+ if output_in_mm:
146
+ one_row_per_frame = scale_descriptors(one_row_per_frame, pixel_size,
147
+ length_measures, area_measures)
148
+ return one_row_per_frame
149
+
150
+
151
+ def compute_one_descriptor_per_colony(binary_vid: NDArray[np.uint8], arena_label: int, timings: NDArray,
152
+ descriptors_dict: dict, output_in_mm: bool, pixel_size: float,
153
+ do_fading: bool, min_colony_size: int, save_coord_specimen: bool):
154
+ dims = binary_vid.shape
155
+ all_descriptors, to_compute_from_sd, length_measures, area_measures = initialize_descriptor_computation(
156
+ descriptors_dict)
157
+ # Objective: create a matrix with 4 columns (time, y, x, colony) containing the coordinates of all colonies
158
+ # against time
159
+ max_colonies = 0
160
+ for t in np.arange(dims[0]):
161
+ nb, shapes = cv2.connectedComponents(binary_vid[t, :, :])
162
+ max_colonies = np.max((max_colonies, nb))
163
+
164
+ time_descriptor_colony = np.zeros((dims[0], len(to_compute_from_sd) * max_colonies * dims[0]),
165
+ dtype=np.float32) # Adjust max_colonies
166
+ colony_number = 0
167
+ colony_id_matrix = np.zeros(dims[1:], dtype=np.uint64)
168
+ coord_colonies = []
169
+ centroids = []
170
+
171
+ # pat_tracker = PercentAndTimeTracker(dims[0], compute_with_elements_number=True)
172
+ for t in tqdm(np.arange(2)):#dims[0])):
173
+ # We rank colonies in increasing order to make sure that the larger colony issued from a colony division
174
+ # keeps the previous colony name.
175
+ # shapes, stats, centers = cc(binary_vid[t, :, :])
176
+ nb, shapes, stats, centers = cv2.connectedComponentsWithStats(binary_vid[t, :, :])
177
+ true_colonies = np.nonzero(stats[:, 4] >= min_colony_size)[0][1:]
178
+ # Consider that shapes bellow 3 pixels are noise. The loop will stop at nb and not compute them
179
+
180
+ # current_percentage, eta = pat_tracker.get_progress(t, element_number=nb)
181
+ # logging.info(f"Arena n°{arena_label}, Colony descriptors computation: {current_percentage}%{eta}")
182
+
183
+ updated_colony_names = np.zeros(1, dtype=np.uint32)
184
+ for colon_i in true_colonies: # 120)):# #92
185
+ current_colony_img = shapes == colon_i
186
+ if current_colony_img.sum() >= 4:
187
+ current_colony_img = current_colony_img.astype(np.uint8)
188
+
189
+ # I/ Find out which names the current colony had at t-1
190
+ colony_previous_names = np.unique(current_colony_img * colony_id_matrix)
191
+ colony_previous_names = colony_previous_names[colony_previous_names != 0]
192
+ # II/ Find out if the current colony name had already been analyzed at t
193
+ # If there no match with the saved colony_id_matrix, assign colony ID
194
+ if t == 0 or len(colony_previous_names) == 0:
195
+ # logging.info("New colony")
196
+ colony_number += 1
197
+ colony_names = [colony_number]
198
+ # If there is at least 1 match with the saved colony_id_matrix, we keep the colony_previous_name(s)
199
+ else:
200
+ colony_names = colony_previous_names.tolist()
201
+ # Handle colony division if necessary
202
+ if np.any(np.isin(updated_colony_names, colony_names)):
203
+ colony_number += 1
204
+ colony_names = [colony_number]
205
+
206
+ # Update colony ID matrix for the current frame
207
+ coords = np.nonzero(current_colony_img)
208
+ colony_id_matrix[coords[0], coords[1]] = colony_names[0]
209
+
210
+ # Add coordinates to coord_colonies
211
+ time_column = np.full(coords[0].shape, t, dtype=np.uint32)
212
+ colony_column = np.full(coords[0].shape, colony_names[0], dtype=np.uint32)
213
+ coord_colonies.append(np.column_stack((time_column, colony_column, coords[0], coords[1])))
214
+
215
+ # Calculate centroid and add to centroids list
216
+ centroid_x, centroid_y = centers[colon_i, :]
217
+ centroids.append((t, colony_names[0], centroid_y, centroid_x))
218
+
219
+ # Compute shape descriptors
220
+ SD = ShapeDescriptors(current_colony_img, to_compute_from_sd)
221
+ # descriptors = list(SD.descriptors.values())
222
+ descriptors = SD.descriptors
223
+ # Adjust descriptors if output_in_mm is specified
224
+ if output_in_mm:
225
+ descriptors = scale_descriptors(descriptors, pixel_size, length_measures, area_measures)
226
+ # Store descriptors in time_descriptor_colony
227
+ descriptor_index = (colony_names[0] - 1) * len(to_compute_from_sd)
228
+ time_descriptor_colony[t, descriptor_index:(descriptor_index + len(descriptors))] = list(
229
+ descriptors.values())
230
+
231
+ updated_colony_names = np.append(updated_colony_names, colony_names)
232
+
233
+ # Reset colony_id_matrix for the next frame
234
+ colony_id_matrix *= binary_vid[t, :, :]
235
+ if len(centroids) > 0:
236
+ centroids = np.array(centroids, dtype=np.float32)
237
+ else:
238
+ centroids = np.zeros((0, 4), dtype=np.float32)
239
+ time_descriptor_colony = time_descriptor_colony[:, :(colony_number * len(to_compute_from_sd))]
240
+ if len(coord_colonies) > 0:
241
+ coord_colonies = np.vstack(coord_colonies)
242
+ if save_coord_specimen:
243
+ coord_colonies = pd.DataFrame(coord_colonies, columns=["time", "colony", "y", "x"])
244
+ coord_colonies.to_csv(
245
+ f"coord_colonies{arena_label}_{colony_number}col_t{dims[0]}_y{dims[1]}_x{dims[2]}.csv",
246
+ sep=';', index=False, lineterminator='\n')
247
+
248
+ centroids = pd.DataFrame(centroids, columns=["time", "colony", "y", "x"])
249
+ centroids.to_csv(
250
+ f"colony_centroids{arena_label}_{colony_number}col_t{dims[0]}_y{dims[1]}_x{dims[2]}.csv",
251
+ sep=';', index=False, lineterminator='\n')
252
+
253
+ # Format the final dataframe to have one row per time frame, and one column per descriptor_colony_name
254
+ one_row_per_frame = pd.DataFrame({'arena': arena_label, 'time': timings,
255
+ 'area_total': binary_vid.sum((1, 2)).astype(np.float64)})
256
+
257
+ if do_fading:
258
+ one_row_per_frame['newly_explored_area'] = get_newly_explored_area(binary_vid)
259
+ if output_in_mm:
260
+ one_row_per_frame = scale_descriptors(one_row_per_frame, pixel_size)
261
+
262
+ column_names = np.char.add(np.repeat(to_compute_from_sd, colony_number),
263
+ np.tile((np.arange(colony_number) + 1).astype(str), len(to_compute_from_sd)))
264
+ time_descriptor_colony = pd.DataFrame(time_descriptor_colony, columns=column_names)
265
+ one_row_per_frame = pd.concat([one_row_per_frame, time_descriptor_colony], axis=1)
266
+
267
+ return one_row_per_frame
268
+
269
+ def initialize_descriptor_computation(descriptors_dict: dict) -> Tuple[list, list, list, list]:
270
+ """
271
+
272
+ Initialize descriptor computation based on available and requested descriptors.
273
+
274
+ Parameters
275
+ ----------
276
+ descriptors_dict : dict
277
+ A dictionary where keys are descriptor names and values are booleans indicating whether
278
+ to compute the corresponding descriptor.
279
+
280
+ Returns
281
+ -------
282
+ tuple
283
+ A tuple containing four lists:
284
+ - all_descriptors: List of all requested descriptor names.
285
+ - to_compute_from_sd: Array of descriptor names that need to be computed from the shape descriptors class.
286
+ - length_measures: Array of descriptor names that are length measures and need to be computed.
287
+ - area_measures: Array of descriptor names that are area measures and need to be computed.
288
+
289
+ Examples
290
+ --------
291
+ >>> descriptors_dict = {'perimeter': True, 'area': False}
292
+ >>> all_descriptors, to_compute_from_sd, length_measures, area_measures = initialize_descriptor_computation(descriptors_dict)
293
+ >>> print(all_descriptors, to_compute_from_sd, length_measures, area_measures)
294
+ ['length'] ['length'] ['length'] []
295
+
296
+ """
297
+ available_descriptors_in_sd = list(from_shape_descriptors_class.keys())
298
+ all_descriptors = []
299
+ to_compute_from_sd = []
300
+ for name, do_compute in descriptors_dict.items():
301
+ if do_compute:
302
+ all_descriptors.append(name)
303
+ if np.isin(name, available_descriptors_in_sd):
304
+ to_compute_from_sd.append(name)
305
+ to_compute_from_sd = np.array(to_compute_from_sd)
306
+ length_measures = to_compute_from_sd[np.isin(to_compute_from_sd, length_descriptors)]
307
+ area_measures = to_compute_from_sd[np.isin(to_compute_from_sd, area_descriptors)]
308
+
309
+ return all_descriptors, to_compute_from_sd, length_measures, area_measures
310
+
311
+ def scale_descriptors(descriptors_dict, pixel_size: float, length_measures: NDArray[str]=None, area_measures: NDArray[str]=None):
312
+ """
313
+ Scale the spatial descriptors in a dictionary based on pixel size.
314
+
315
+ Parameters
316
+ ----------
317
+ descriptors_dict : dict
318
+ Dictionary containing spatial descriptors.
319
+ pixel_size : float
320
+ Pixel size used for scaling.
321
+ length_measures : numpy.ndarray, optional
322
+ Array of descriptors that represent lengths. If not provided,
323
+ they will be initialized.
324
+ area_measures : numpy.ndarray, optional
325
+ Array of descriptors that represent areas. If not provided,
326
+ they will be initialized.
327
+
328
+ Returns
329
+ -------
330
+ dict
331
+ Dictionary with scaled spatial descriptors.
332
+
333
+ Examples
334
+ --------
335
+ >>> from numpy import array as ndarray
336
+ >>> descriptors_dict = {'length': ndarray([1, 2]), 'area': ndarray([3, 4])}
337
+ >>> pixel_size = 0.5
338
+ >>> scaled_dict = scale_descriptors(descriptors_dict, pixel_size)
339
+ >>> print(scaled_dict)
340
+ {'length': array([0.5, 1.]), 'area': array([1.58421369, 2.])}
341
+ """
342
+ if length_measures is None or area_measures is None:
343
+ to_compute_from_sd = np.array(list(descriptors_dict.keys()))
344
+ length_measures = to_compute_from_sd[np.isin(to_compute_from_sd, length_descriptors)]
345
+ area_measures = to_compute_from_sd[np.isin(to_compute_from_sd, area_descriptors)]
346
+ for descr in length_measures:
347
+ descriptors_dict[descr] *= pixel_size
348
+ for descr in area_measures:
349
+ descriptors_dict[descr] *= np.sqrt(pixel_size)
350
+ return descriptors_dict
351
+
352
+
353
+ class ShapeDescriptors:
354
+ """
355
+ This class takes :
356
+ - a binary image of 0 and 1 drawing one shape
357
+ - a list of descriptors to calculate from that image
358
+ ["area", "perimeter", "circularity", "rectangularity", "total_hole_area", "solidity", "convexity",
359
+ "eccentricity", "euler_number",
360
+
361
+ "standard_deviation_y", "standard_deviation_x", "skewness_y", "skewness_x", "kurtosis_y", "kurtosis_x",
362
+ "major_axis_len", "minor_axis_len", "axes_orientation",
363
+
364
+ "mo", "contours", "min_bounding_rectangle", "convex_hull"]
365
+
366
+ Be careful! mo, contours, min_bounding_rectangle, convex_hull,
367
+ standard_deviations, skewness and kurtosis are not atomics
368
+ https://www.researchgate.net/publication/27343879_Estimators_for_Orientation_and_Anisotropy_in_Digitized_Images
369
+ """
370
+
371
+ def __init__(self, binary_image, wanted_descriptors_list):
372
+ """
373
+ Class to compute various descriptors for a binary image.
374
+
375
+ Parameters
376
+ ----------
377
+ binary_image : ndarray
378
+ Binary image used to compute the descriptors.
379
+ wanted_descriptors_list : list
380
+ List of strings with the names of the wanted descriptors.
381
+
382
+ Attributes
383
+ ----------
384
+ binary_image : ndarray
385
+ The binary image.
386
+ descriptors : dict
387
+ Dictionary containing the computed descriptors.
388
+ mo : float or None, optional
389
+ Moment of inertia (default is `None`).
390
+ area : int or None, optional
391
+ Area of the object (default is `None`).
392
+ contours : ndarray or None, optional
393
+ Contours of the object (default is `None`).
394
+ min_bounding_rectangle : tuple or None, optional
395
+ Minimum bounding rectangle of the object (default is `None`).
396
+ convex_hull : ndarray or None, optional
397
+ Convex hull of the object (default is `None`).
398
+ major_axis_len : float or None, optional
399
+ Major axis length of the object (default is `None`).
400
+ minor_axis_len : float or None, optional
401
+ Minor axis length of the object (default is `None`).
402
+ axes_orientation : float or None, optional
403
+ Orientation of the axes (default is `None`).
404
+ sx : float or None, optional
405
+ Standard deviation in x-axis (default is `None`).
406
+ kx : float or None, optional
407
+ Kurtosis in x-axis (default is `None`).
408
+ skx : float or None, optional
409
+ Skewness in x-axis (default is `None`).
410
+ perimeter : float or None, optional
411
+ Perimeter of the object (default is `None`).
412
+ convexity : float or None, optional
413
+ Convexity of the object (default is `None`).
414
+
415
+ Examples
416
+ --------
417
+ >>> binary_image = np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8)
418
+ >>> wanted_descriptors_list = ["area", "perimeter"]
419
+ >>> SD = ShapeDescriptors(binary_image, wanted_descriptors_list)
420
+ >>> SD.descriptors
421
+ {'area': np.uint64(9), 'perimeter': 8.0}
422
+ """
423
+ # Give a None value to each parameters whose presence is assessed before calculation (less calculus for speed)
424
+ self.mo = None
425
+ self.area = None
426
+ self.contours = None
427
+ self.min_bounding_rectangle = None
428
+ self.convex_hull = None
429
+ self.major_axis_len = None
430
+ self.minor_axis_len = None
431
+ self.axes_orientation = None
432
+ self.sx = None
433
+ self.kx = None
434
+ self.skx = None
435
+ self.perimeter = None
436
+ self.convexity = None
437
+
438
+ self.binary_image = binary_image
439
+ if self.binary_image.dtype == 'bool':
440
+ self.binary_image = self.binary_image.astype(np.uint8)
441
+
442
+ self.descriptors = {i: np.empty(0, dtype=np.float64) for i in wanted_descriptors_list}
443
+ self.get_area()
444
+
445
+ for name in self.descriptors.keys():
446
+ if name == "mo":
447
+ self.get_mo()
448
+ self.descriptors[name] = self.mo
449
+ elif name == "area":
450
+ self.descriptors[name] = self.area
451
+ elif name == "contours":
452
+ self.get_contours()
453
+ self.descriptors[name] = self.contours
454
+ elif name == "min_bounding_rectangle":
455
+ self.get_min_bounding_rectangle()
456
+ self.descriptors[name] = self.min_bounding_rectangle
457
+ elif name == "major_axis_len":
458
+ self.get_major_axis_len()
459
+ self.descriptors[name] = self.major_axis_len
460
+ elif name == "minor_axis_len":
461
+ self.get_minor_axis_len()
462
+ self.descriptors[name] = self.minor_axis_len
463
+ elif name == "axes_orientation":
464
+ self.get_inertia_axes()
465
+ self.descriptors[name] = self.axes_orientation
466
+ elif name == "standard_deviation_y":
467
+ self.get_standard_deviations()
468
+ self.descriptors[name] = self.sy
469
+ elif name == "standard_deviation_x":
470
+ self.get_standard_deviations()
471
+ self.descriptors[name] = self.sx
472
+ elif name == "skewness_y":
473
+ self.get_skewness()
474
+ self.descriptors[name] = self.sky
475
+ elif name == "skewness_x":
476
+ self.get_skewness()
477
+ self.descriptors[name] = self.skx
478
+ elif name == "kurtosis_y":
479
+ self.get_kurtosis()
480
+ self.descriptors[name] = self.ky
481
+ elif name == "kurtosis_x":
482
+ self.get_kurtosis()
483
+ self.descriptors[name] = self.kx
484
+ elif name == "convex_hull":
485
+ self.get_convex_hull()
486
+ self.descriptors[name] = self.convex_hull
487
+ elif name == "perimeter":
488
+ self.get_perimeter()
489
+ self.descriptors[name] = self.perimeter
490
+ elif name == "circularity":
491
+ self.get_circularity()
492
+ self.descriptors[name] = self.circularity
493
+ elif name == "rectangularity":
494
+ self.get_rectangularity()
495
+ self.descriptors[name] = self.rectangularity
496
+ elif name == "total_hole_area":
497
+ self.get_total_hole_area()
498
+ self.descriptors[name] = self.total_hole_area
499
+ elif name == "solidity":
500
+ self.get_solidity()
501
+ self.descriptors[name] = self.solidity
502
+ elif name == "convexity":
503
+ self.get_convexity()
504
+ self.descriptors[name] = self.convexity
505
+ elif name == "eccentricity":
506
+ self.get_eccentricity()
507
+ self.descriptors[name] = self.eccentricity
508
+ elif name == "euler_number":
509
+ self.get_euler_number()
510
+ self.descriptors[name] = self.euler_number
511
+
512
+ """
513
+ The following methods can be called to compute parameters for descriptors requiring it
514
+ """
515
+
516
+ def get_mo(self):
517
+ """
518
+ Get moments of a binary image.
519
+
520
+ Calculate the image moments for a given binary image using OpenCV's
521
+ `cv2.moments` function and then translate these moments into a formatted
522
+ dictionary.
523
+
524
+ Notes
525
+ -----
526
+ This function assumes the binary image has already been processed and is in a
527
+ suitable format for moment calculation.
528
+
529
+ Returns
530
+ -------
531
+ None
532
+
533
+ Examples
534
+ --------
535
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["mo"])
536
+ >>> print(SD.mo["m00"])
537
+ 9.0
538
+ """
539
+ self.mo = translate_dict(cv2.moments(self.binary_image))
540
+
541
+ def get_area(self):
542
+ """
543
+ Calculate the area of a binary image by summing its pixel values.
544
+
545
+ This function computes the area covered by white pixels (value 1) in a binary image,
546
+ which is equivalent to counting the number of 'on' pixels.
547
+
548
+ Notes
549
+ -----
550
+ Sums values in `self.binary_image` and stores the result in `self.area`.
551
+
552
+ Returns
553
+ -------
554
+ None
555
+
556
+ Examples
557
+ --------
558
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["area"])
559
+ >>> print(SD.area)
560
+ 9.0
561
+ """
562
+ self.area = self.binary_image.sum()
563
+
564
+ def get_contours(self):
565
+ """
566
+ Find and process the largest contour in a binary image.
567
+
568
+ Retrieves contours from a binary image, calculates the Euler number,
569
+ and identifies the largest contour based on its length.
570
+
571
+ Notes
572
+ -----
573
+ This function modifies the internal state of the `self` object to store
574
+ the largest contour and Euler number.
575
+
576
+ Returns
577
+ -------
578
+ None
579
+
580
+ Examples
581
+ --------
582
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["euler_number"])
583
+ >>> print(len(SD.contours))
584
+ 8
585
+ """
586
+ if self.area == 0:
587
+ self.euler_number = 0.
588
+ self.contours = np.array([], np.uint8)
589
+ else:
590
+ contours, hierarchy = cv2.findContours(self.binary_image, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
591
+ nb, shapes = cv2.connectedComponents(self.binary_image, ltype=cv2.CV_16U)
592
+ self.euler_number = (nb - 1) - len(contours)
593
+ self.contours = contours[0]
594
+ if len(contours) > 1:
595
+ all_lengths = np.zeros(len(contours))
596
+ for i, contour in enumerate(contours):
597
+ all_lengths[i] = len(contour)
598
+ self.contours = contours[np.argmax(all_lengths)]
599
+
600
+ def get_min_bounding_rectangle(self):
601
+ """
602
+ Retrieve the minimum bounding rectangle from the contours of an image.
603
+
604
+ This method calculates the smallest area rectangle that can enclose
605
+ the object outlines present in the image, which is useful for
606
+ object detection and analysis tasks.
607
+
608
+ Notes
609
+ -----
610
+ - The bounding rectangle is calculated only if contours are available.
611
+ If not, they will be retrieved first before calculating the rectangle.
612
+
613
+ Raises
614
+ ------
615
+ RuntimeError
616
+ If the contours are not available and cannot be retrieved,
617
+ indicating a problem with the image or preprocessing steps.
618
+
619
+ Returns
620
+ -------
621
+ None
622
+
623
+ Examples
624
+ --------
625
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["rectangularity"])
626
+ >>> print(len(SD.min_bounding_rectangle))
627
+ 3
628
+ """
629
+ if self.area == 0:
630
+ self.min_bounding_rectangle = np.array([], np.uint8)
631
+ else:
632
+ if self.contours is None:
633
+ self.get_contours()
634
+ if len(self.contours) == 0:
635
+ self.min_bounding_rectangle = np.array([], np.uint8)
636
+ else:
637
+ self.min_bounding_rectangle = cv2.minAreaRect(self.contours) # ((cx, cy), (width, height), angle)
638
+
639
+ def get_inertia_axes(self):
640
+ """
641
+ Calculate and set the moments of inertia properties of an object.
642
+
643
+ This function computes the centroid, major axis length,
644
+ minor axis length, and axes orientation for an object. It
645
+ first ensures that the moments of inertia (`mo`) attribute is available,
646
+ computing them if necessary, before using the `get_inertia_axes` function.
647
+
648
+ Returns
649
+ -------
650
+ None
651
+
652
+ This method sets the following attributes:
653
+ - `cx` : float
654
+ The x-coordinate of the centroid.
655
+ - `cy` : float
656
+ The y-coordinate of the centroid.
657
+ - `major_axis_len` : float
658
+ The length of the major axis.
659
+ - `minor_axis_len` : float
660
+ The length of the minor axis.
661
+ - `axes_orientation` : float
662
+ The orientation angle of the axes.
663
+
664
+ Raises
665
+ ------
666
+ ValueError
667
+ If there is an issue with the moments of inertia computation.
668
+
669
+ Notes
670
+ -----
671
+ This function modifies in-place the object's attributes related to its geometry.
672
+
673
+ Examples
674
+ --------
675
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["major_axis_len"])
676
+ >>> print(SD.axes_orientation)
677
+ 0.0
678
+ """
679
+ if self.mo is None:
680
+ self.get_mo()
681
+ if self.area == 0:
682
+ self.cx, self.cy, self.major_axis_len, self.minor_axis_len, self.axes_orientation = 0, 0, 0, 0, 0
683
+ else:
684
+ self.cx, self.cy, self.major_axis_len, self.minor_axis_len, self.axes_orientation = get_inertia_axes(self.mo)
685
+
686
+ def get_standard_deviations(self):
687
+ """
688
+ Calculate and store standard deviations along x and y (sx, sy).
689
+
690
+ Notes
691
+ -----
692
+ Requires centroid and moments; values are stored in `self.sx` and `self.sy`.
693
+
694
+ Returns
695
+ -------
696
+ None
697
+
698
+ Examples
699
+ --------
700
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["standard_deviation_x", "standard_deviation_y"])
701
+ >>> print(SD.sx, SD.sy)
702
+ 0.816496580927726 0.816496580927726
703
+ """
704
+ if self.sx is None:
705
+ if self.axes_orientation is None:
706
+ self.get_inertia_axes()
707
+ self.sx, self.sy = get_standard_deviations(self.mo, self.binary_image, self.cx, self.cy)
708
+
709
+ def get_skewness(self):
710
+ """
711
+ Calculate and store skewness along x and y (skx, sky).
712
+
713
+ This function computes the skewness about the x-axis and y-axis of
714
+ an image. Skewness is a measure of the asymmetry of the probability
715
+ distribution of values in an image.
716
+
717
+ Notes
718
+ -----
719
+ Requires standard deviations; values are stored in `self.skx` and `self.sky`.
720
+
721
+ Returns
722
+ -------
723
+ None
724
+
725
+ Examples
726
+ --------
727
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["skewness_x", "skewness_y"])
728
+ >>> print(SD.skx, SD.sky)
729
+ 0.0 0.0
730
+ """
731
+ if self.skx is None:
732
+ if self.sx is None:
733
+ self.get_standard_deviations()
734
+
735
+ self.skx, self.sky = get_skewness(self.mo, self.binary_image, self.cx, self.cy, self.sx, self.sy)
736
+
737
+ def get_kurtosis(self):
738
+ """
739
+ Calculates the kurtosis of the image moments.
740
+
741
+ Kurtosis is a statistical measure that describes the shape of
742
+ a distribution's tails in relation to its overall shape. It is
743
+ used here in the context of image moments analysis.
744
+
745
+ Notes
746
+ -----
747
+ This function first checks if the kurtosis values have already been calculated.
748
+ If not, it calculates them using the `get_kurtosis` function.
749
+
750
+ Examples
751
+ --------
752
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["kurtosis_x", "kurtosis_y"])
753
+ >>> print(SD.kx, SD.ky)
754
+ 1.5 1.5
755
+ """
756
+ if self.kx is None:
757
+ if self.sx is None:
758
+ self.get_standard_deviations()
759
+
760
+ self.kx, self.ky = get_kurtosis(self.mo, self.binary_image, self.cx, self.cy, self.sx, self.sy)
761
+
762
+ def get_convex_hull(self):
763
+ """
764
+ Compute and store the convex hull of the object's contour.
765
+
766
+ Notes
767
+ -----
768
+ Stores the result in `self.convex_hull`. Computes contours if needed.
769
+
770
+ Returns
771
+ -------
772
+ None
773
+
774
+ Examples
775
+ --------
776
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["solidity"])
777
+ >>> print(len(SD.convex_hull))
778
+ 4
779
+ """
780
+ if self.area == 0:
781
+ self.convex_hull = np.array([], np.uint8)
782
+ else:
783
+ if self.contours is None:
784
+ self.get_contours()
785
+ self.convex_hull = cv2.convexHull(self.contours)
786
+
787
+ def get_perimeter(self):
788
+ """
789
+ Compute and store the contour perimeter length.
790
+
791
+ Notes
792
+ -----
793
+ Computes contours if needed and stores the length in `self.perimeter`.
794
+
795
+ Returns
796
+ -------
797
+ None
798
+
799
+ Examples
800
+ --------
801
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["perimeter"])
802
+ >>> print(SD.perimeter)
803
+ 8.0
804
+ """
805
+ if self.area == 0:
806
+ self.perimeter = 0.
807
+ else:
808
+ if self.contours is None:
809
+ self.get_contours()
810
+ if len(self.contours) == 0:
811
+ self.perimeter = 0.
812
+ else:
813
+ self.perimeter = cv2.arcLength(self.contours, True)
814
+
815
+ def get_circularity(self):
816
+ """
817
+ Compute and store circularity: 4πA / P².
818
+
819
+ Notes
820
+ -----
821
+ Uses `self.area` and `self.perimeter`; stores result in `self.circularity`.
822
+
823
+ Returns
824
+ -------
825
+ None
826
+
827
+ Examples
828
+ --------
829
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["circularity"])
830
+ >>> print(SD.circularity)
831
+ 1.7671458676442586
832
+ """
833
+ if self.area == 0:
834
+ self.circularity = 0.
835
+ else:
836
+ if self.perimeter is None:
837
+ self.get_perimeter()
838
+ if self.perimeter == 0:
839
+ self.circularity = 0.
840
+ else:
841
+ self.circularity = (4 * np.pi * self.binary_image.sum()) / np.square(self.perimeter)
842
+
843
+ def get_rectangularity(self):
844
+ """
845
+ Compute and store rectangularity: area / bounding-rectangle-area.
846
+
847
+ Notes
848
+ -----
849
+ Uses `self.binary_image` and `self.min_bounding_rectangle`. Computes the MBR if needed.
850
+
851
+ Returns
852
+ -------
853
+ None
854
+
855
+ Examples
856
+ --------
857
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["rectangularity"])
858
+ >>> print(SD.rectangularity)
859
+ 2.25
860
+ """
861
+ if self.area == 0:
862
+ self.rectangularity = 0.
863
+ else:
864
+ if self.min_bounding_rectangle is None:
865
+ self.get_min_bounding_rectangle()
866
+ bounding_rectangle_area = self.min_bounding_rectangle[1][0] * self.min_bounding_rectangle[1][1]
867
+ if bounding_rectangle_area == 0:
868
+ self.rectangularity = 0.
869
+ else:
870
+ self.rectangularity = self.binary_image.sum() / bounding_rectangle_area
871
+
872
+ def get_total_hole_area(self):
873
+ """
874
+ Calculate the total area of holes in a binary image.
875
+
876
+ This function uses connected component labeling to detect and
877
+ measure the area of holes in a binary image.
878
+
879
+ Returns
880
+ -------
881
+ float
882
+ The total area of all detected holes in the binary image.
883
+
884
+ Notes
885
+ -----
886
+ This function assumes that the binary image has been pre-processed
887
+ and that holes are represented as connected components of zero
888
+ pixels within the foreground
889
+
890
+ Examples
891
+ --------
892
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["total_hole_area"])
893
+ >>> print(SD.total_hole_area)
894
+ 0
895
+ """
896
+ nb, new_order = cv2.connectedComponents(1 - self.binary_image)
897
+ if nb > 2:
898
+ self.total_hole_area = (new_order > 1).sum()
899
+ else:
900
+ self.total_hole_area = 0.
901
+
902
+ def get_solidity(self):
903
+ """
904
+ Compute and store solidity: contour area / convex hull area.
905
+
906
+ Extended Summary
907
+ ----------------
908
+ The solidity is a dimensionless measure that compares the area of a shape to
909
+ its convex hull. A solidity of 1 means the contour is fully convex, while a
910
+ value less than 1 indicates concavities.
911
+
912
+ Notes
913
+ -----
914
+ If the convex hull area is 0 or absent, solidity is set to 0.
915
+
916
+ Returns
917
+ -------
918
+ None
919
+
920
+ Examples
921
+ --------
922
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["solidity"])
923
+ >>> print(SD.solidity)
924
+ 1.0
925
+ """
926
+ if self.area == 0:
927
+ self.solidity = 0.
928
+ else:
929
+ if self.convex_hull is None:
930
+ self.get_convex_hull()
931
+ if len(self.convex_hull) == 0:
932
+ self.solidity = 0.
933
+ else:
934
+ hull_area = cv2.contourArea(self.convex_hull)
935
+ if hull_area == 0:
936
+ self.solidity = 0.
937
+ else:
938
+ self.solidity = cv2.contourArea(self.contours) / hull_area
939
+
940
+ def get_convexity(self):
941
+ """
942
+ Compute and store convexity: convex hull perimeter / contour perimeter.
943
+
944
+ Notes
945
+ -----
946
+ Requires `self.perimeter` and `self.convex_hull`.
947
+
948
+ Returns
949
+ -------
950
+ None
951
+
952
+ Examples
953
+ --------
954
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["convexity"])
955
+ >>> print(SD.convexity)
956
+ 1.0
957
+ """
958
+ if self.perimeter is None:
959
+ self.get_perimeter()
960
+ if self.convex_hull is None:
961
+ self.get_convex_hull()
962
+ if self.perimeter == 0 or len(self.convex_hull) == 0:
963
+ self.convexity = 0.
964
+ else:
965
+ self.convexity = cv2.arcLength(self.convex_hull, True) / self.perimeter
966
+
967
+ def get_eccentricity(self):
968
+ """
969
+ Compute and store eccentricity from major and minor axis lengths.
970
+
971
+ Notes
972
+ -----
973
+ Calls `get_inertia_axes()` if needed and stores result in `self.eccentricity`.
974
+
975
+ Returns
976
+ -------
977
+ None
978
+
979
+ Examples
980
+ --------
981
+ >>> SD = ShapeDescriptors(np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]], dtype=np.uint8), ["eccentricity"])
982
+ >>> print(SD.eccentricity)
983
+ 0.0
984
+ """
985
+ self.get_inertia_axes()
986
+ if self.major_axis_len == 0:
987
+ self.eccentricity = 0.
988
+ else:
989
+ self.eccentricity = np.sqrt(1 - np.square(self.minor_axis_len / self.major_axis_len))
990
+
991
+ def get_euler_number(self):
992
+ """
993
+ Ensure contours are computed; stores Euler number in `self.euler_number` via `get_contours()`.
994
+
995
+ Returns
996
+ -------
997
+ None
998
+
999
+ Notes
1000
+ -----
1001
+ Euler number is computed in `get_contours()` as `(components - 1) - len(contours)`.
1002
+ """
1003
+ if self.contours is None:
1004
+ self.get_contours()
1005
+
1006
+ def get_major_axis_len(self):
1007
+ """
1008
+ Ensure the major axis length is computed and stored in `self.major_axis_len`.
1009
+
1010
+ Returns
1011
+ -------
1012
+ None
1013
+
1014
+ Notes
1015
+ -----
1016
+ Triggers `get_inertia_axes()` if needed.
1017
+
1018
+ Examples
1019
+ --------
1020
+ >>> SD = ShapeDescriptors(np.array([[0, 1, 0], [0, 1, 0], [0, 1, 0]], dtype=np.uint8), ["major_axis_len"])
1021
+ >>> print(SD.major_axis_len)
1022
+ 2.8284271247461907
1023
+ """
1024
+ if self.major_axis_len is None:
1025
+ self.get_inertia_axes()
1026
+
1027
+ def get_minor_axis_len(self):
1028
+ """
1029
+ Ensure the minor axis length is computed and stored in `self.minor_axis_len`.
1030
+
1031
+ Returns
1032
+ -------
1033
+ None
1034
+
1035
+ Notes
1036
+ -----
1037
+ Triggers `get_inertia_axes()` if needed.
1038
+
1039
+ Examples
1040
+ --------
1041
+ >>> SD = ShapeDescriptors(np.array([[0, 1, 0], [0, 1, 0], [0, 1, 0]], dtype=np.uint8), ["minor_axis_len"])
1042
+ >>> print(SD.minor_axis_len)
1043
+ 0.0
1044
+ """
1045
+ if self.minor_axis_len is None:
1046
+ self.get_inertia_axes()
1047
+
1048
+ def get_axes_orientation(self):
1049
+ """
1050
+ Ensure the axes orientation angle is computed and stored in `self.axes_orientation`.
1051
+
1052
+ Returns
1053
+ -------
1054
+ None
1055
+
1056
+ Notes
1057
+ -----
1058
+ Calls `get_inertia_axes()` if orientation is not yet computed.
1059
+
1060
+ Examples
1061
+ --------
1062
+ >>> SD = ShapeDescriptors(np.array([[0, 1, 0], [0, 1, 0], [0, 1, 0]], dtype=np.uint8), ["axes_orientation"])
1063
+ >>> print(SD.axes_orientation)
1064
+ 1.5707963267948966
1065
+ """
1066
+ if self.axes_orientation is None:
1067
+ self.get_inertia_axes()
1068
+