Simple-Track 2.0.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.
simpletrack/feature.py ADDED
@@ -0,0 +1,322 @@
1
+ import datetime as dt
2
+ from typing import Union
3
+
4
+ import numpy as np
5
+ from numpy.typing import NDArray
6
+
7
+ from simpletrack.utils import check_arrays, check_valid_ids, native
8
+
9
+
10
+ class Feature:
11
+ """
12
+ Object containing details about a specific feature, including its id, time,
13
+ centroid, extreme value, lifetime, and whether it has undergone any
14
+ mergers of splits in the current timestep.
15
+ """
16
+
17
+ def __init__(
18
+ self, id: int, feature_coords: NDArray[np.integer], time: dt.datetime
19
+ ) -> None:
20
+ check_arrays(feature_coords, ndim=2, dtype=int)
21
+ id = check_valid_ids(id)
22
+ self._id = native(id)
23
+ self._provisional_id = None
24
+ self._feature_coords = feature_coords
25
+ self._time = time
26
+ self._centroid = None
27
+ self._lifetime = 1
28
+ self._final_timestep = False
29
+ self._accreted = []
30
+ self._accreted_in_next_frame_by = None
31
+ self._parent = None
32
+ self._children = []
33
+ self._dydx = ()
34
+ self._extreme = None
35
+
36
+ def __repr__(self) -> str:
37
+ repr_str = f"Feature id: {self._id} (provisionally {self._provisional_id}), "
38
+ repr_str += f"lifetime: {self._lifetime} timestep(s) at time: {self._time}"
39
+ return repr_str
40
+
41
+ def __eq__(self, other):
42
+ return (
43
+ self._time == other._time
44
+ and self._id == other._id
45
+ and np.array_equal(self._feature_coords, other._feature_coords)
46
+ )
47
+
48
+ @property
49
+ def id(self) -> int:
50
+ """
51
+ The id of the Feature, a positive nonzero integer
52
+ """
53
+ return self._id
54
+
55
+ @property
56
+ def provisional_id(self) -> int:
57
+ """
58
+ A provisional id for the Feature, used when matching Features between Frames
59
+ before final id assignement.
60
+ """
61
+ return self._provisional_id
62
+
63
+ @property
64
+ def centroid(self) -> tuple:
65
+ """
66
+ Central point of the Feature, calculated as the mean of all y, x
67
+ coordinates spanned by the Feature
68
+ """
69
+ if self._centroid is None:
70
+ self._centroid = self.calculate_centroid()
71
+ return self._centroid
72
+
73
+ @property
74
+ def time(self) -> dt.datetime:
75
+ """
76
+ Time that the Feature exists at.
77
+ """
78
+ return self._time
79
+
80
+ @property
81
+ def coords(self) -> NDArray[np.integer]:
82
+ """
83
+ Coordinates spanned by the Feature, as a 2D array of shape (2, n),
84
+ where the first row contains y coordinates, and the second
85
+ row contains x coordinates.
86
+ """
87
+ return self._feature_coords
88
+
89
+ @property
90
+ def lifetime(self) -> int:
91
+ """
92
+ Lifetime that the current Feature has existed for.
93
+ If lifetime = 1, it initiated at the current timestep.
94
+ """
95
+ return self._lifetime
96
+
97
+ @property
98
+ def accreted(self) -> list[int]:
99
+ """
100
+ List of Feature ids that have been accreted by this Feature in the
101
+ current timestep, if any. Return None if no accreted features
102
+ """
103
+ if len(self._accreted) < 1:
104
+ return None
105
+ return self._accreted
106
+
107
+ @property
108
+ def accreted_in_next_frame_by(self) -> int:
109
+ """
110
+ ID of Feature that accretes this Feature in the next frame, if any.
111
+ This will not be known until the next frame of data has been processed.
112
+ """
113
+ return self._accreted_in_next_frame_by
114
+
115
+ @property
116
+ def parent(self) -> int:
117
+ """
118
+ If this Feature split from another Feature in the current timestep,
119
+ this is the ID of the parent Feature, Otherwise, this is None.
120
+ """
121
+ return self._parent
122
+
123
+ @property
124
+ def children(self) -> list[int]:
125
+ """
126
+ If other Features split from this Feature in the current timestep,
127
+ this is the list of IDs of those child Features. Otherwise this is None
128
+ """
129
+ if len(self._children) < 1:
130
+ return None
131
+ return self._children
132
+
133
+ @property
134
+ def dydx(self) -> tuple:
135
+ """
136
+ Motion vector that translated the current Feature from its position in the
137
+ previous frame to its position in the current frame. This is calculated from
138
+ the mean of y_flow, x_flow values spanned by the Feature in the
139
+ frame with the same timestamp.
140
+ """
141
+ return native(self._dydx)
142
+
143
+ @property
144
+ def extreme(self) -> float:
145
+ """
146
+ Maximum value of the Feature in the raw input data
147
+ """
148
+ return self._extreme
149
+
150
+ @coords.setter
151
+ def coords(self, new_coords: NDArray[np.integer]) -> None:
152
+ self._feature_coords = new_coords
153
+ self._centroid = self.calculate_centroid() # Update centroid when coords change
154
+
155
+ @parent.setter
156
+ def parent(self, parent_id: int) -> None:
157
+ if parent_id is None:
158
+ self._parent = None
159
+ return
160
+
161
+ parent_id = check_valid_ids(parent_id)
162
+ self._parent = native(parent_id)
163
+
164
+ @dydx.setter
165
+ def dydx(self, dy_dx: tuple) -> None:
166
+ self._dydx = native(dy_dx)
167
+
168
+ @id.setter
169
+ def id(self, _id: int) -> None:
170
+ _id = check_valid_ids(_id)
171
+ self._id = native(_id)
172
+
173
+ @lifetime.setter
174
+ def lifetime(self, lifetime: int) -> None:
175
+ self._lifetime = native(lifetime)
176
+
177
+ @provisional_id.setter
178
+ def provisional_id(self, _id: int) -> None:
179
+ if _id is not None:
180
+ _id = check_valid_ids(_id)
181
+ self._provisional_id = native(_id)
182
+
183
+ @accreted_in_next_frame_by.setter
184
+ def accreted_in_next_frame_by(self, id_of_accreting_feature: int):
185
+ id_of_accreting_feature = check_valid_ids(id_of_accreting_feature)
186
+ self._accreted_in_next_frame_by = id_of_accreting_feature
187
+
188
+ @extreme.setter
189
+ def extreme(self, extreme_val: float) -> None:
190
+ self._extreme = extreme_val
191
+
192
+ def calculate_centroid(self) -> tuple:
193
+ """
194
+ Calculate centroid of the Feature as the mean of all y, x coordinates
195
+ spanned by the Feature
196
+
197
+ Returns:
198
+ tuple: (y_centroid, x_centroid)
199
+ """
200
+ y_centroid = native(np.mean(self._feature_coords[0, :]))
201
+ x_centroid = native(np.mean(self._feature_coords[1, :]))
202
+ return (y_centroid, x_centroid)
203
+
204
+ def accrete_ids(self, feature_ids: int | list[int], replace: bool = False) -> None:
205
+ """
206
+ Add input ids to the list of accreted_ids contained in this Feature.
207
+
208
+ Args:
209
+ feature_ids (int | list[int]):
210
+ ID or list of IDs of features to be added to the accreted list for
211
+ this Feature
212
+ replace (bool, optional):
213
+ If True, replaces existing accreted ids with the input ids,
214
+ rather than adding inputs to the existing list.
215
+ Defaults to False.
216
+ """
217
+ feature_ids = check_valid_ids(feature_ids)
218
+ existing_ids = [] if replace else self._accreted
219
+
220
+ if isinstance(feature_ids, int):
221
+ existing_ids.append(native(feature_ids))
222
+ elif isinstance(feature_ids, np.ndarray):
223
+ existing_ids.extend(feature_ids.tolist())
224
+ else:
225
+ existing_ids.extend(feature_ids)
226
+
227
+ self._accreted = existing_ids
228
+
229
+ def spawns(self, feature_ids: int | list[int], replace: bool = False) -> None:
230
+ """
231
+ Add input ids to the list of child ids for this Feature.
232
+ If replace is True, replaces existing child ids with the input ids,
233
+ rather than adding to existing list.
234
+ """
235
+ feature_ids = check_valid_ids(feature_ids)
236
+ existing_ids = [] if replace else self._children
237
+
238
+ if isinstance(feature_ids, int):
239
+ existing_ids.append(feature_ids)
240
+ elif isinstance(feature_ids, np.ndarray):
241
+ existing_ids.extend(feature_ids.tolist())
242
+ else:
243
+ existing_ids.extend(feature_ids)
244
+
245
+ self._children = existing_ids
246
+
247
+ def get_size(self) -> int:
248
+ """
249
+ Get number of pixels spanned by the Feature, calculated as the number of
250
+ coordinate pairs in feature_coords array
251
+ """
252
+ return len(self._feature_coords[0])
253
+
254
+ def set_as_final_timestep(self) -> None:
255
+ """
256
+ Set the feature as being in its final timestep, which means it is either
257
+ accreting into another feature in the next frame, or it is dissipating
258
+ in the next frame.
259
+ """
260
+ self._final_timestep = True
261
+
262
+ def summarise(
263
+ self, output_type: str = "str", headers_only: bool = False
264
+ ) -> Union[str, dict, list]:
265
+ """
266
+ Return a summary of the Feature properties
267
+
268
+ Args:
269
+ output_type (str, optional):
270
+ Format to return the summary in. Either "str" or "dict"
271
+ Defaults to "str".
272
+ headers_only (bool, optional):
273
+ Whether to return just the headers of the summary
274
+ (i.e., the keys of the summary dict)
275
+ Defaults to False.
276
+ """
277
+ summary = {
278
+ "id": self._id,
279
+ "centroid": self.centroid,
280
+ "size": self.get_size(),
281
+ # native() does not convert dydx to python type for some reason
282
+ "dydx": tuple([val.item() for val in self._dydx]),
283
+ "extreme": self._extreme,
284
+ "lifetime": self._lifetime,
285
+ "accreted": self._accreted,
286
+ # This will not be output properly in the current workflow, since each
287
+ # output occurs after the current frame analysis has finished, but this
288
+ # can only be set after comparison with the next frame of data.
289
+ # "accredted_in_next_frame_by": self._accreted_in_next_frame_by,
290
+ "parent": self._parent,
291
+ "children": self._children,
292
+ }
293
+ if headers_only:
294
+ return list(summary.keys())
295
+ if output_type == "str":
296
+ return str(summary)
297
+ elif output_type == "dict":
298
+ return summary
299
+ else:
300
+ raise ValueError("output_type must be 'str' or 'dict'")
301
+
302
+ def is_new(self) -> bool:
303
+ """
304
+ Returns bool whether the Feature is 'new' in the sense that it
305
+ has lifetime of 1 AND it has not split from another Feature
306
+ (i.e., it has no parent)
307
+ """
308
+ return self._lifetime == 1 and self._parent is None
309
+
310
+ def is_dissipating(self) -> bool:
311
+ """
312
+ Returns bool whether the Feature is 'dissipating' in the sense that
313
+ this is its final timestep AND it has not been accreted by another Feature
314
+ """
315
+ return self._final_timestep and self._accreted_in_next_frame_by is None
316
+
317
+ def is_final_timestep(self) -> bool:
318
+ """
319
+ Returns bool whether this is the final timestep for the Feature, i.e., it is
320
+ either dissipating or accreting into another Feature
321
+ """
322
+ return self._final_timestep