calsipro 0.10.0__tar.gz
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.
- calsipro-0.10.0/PKG-INFO +31 -0
- calsipro-0.10.0/pyproject.toml +50 -0
- calsipro-0.10.0/src/calsipro/__init__.py +0 -0
- calsipro-0.10.0/src/calsipro/analysis.py +365 -0
- calsipro-0.10.0/src/calsipro/cli.py +443 -0
- calsipro-0.10.0/src/calsipro/io.py +129 -0
- calsipro-0.10.0/src/calsipro/organoid_database.py +98 -0
- calsipro-0.10.0/src/calsipro/peak_calling.py +170 -0
- calsipro-0.10.0/src/calsipro/py.typed +0 -0
- calsipro-0.10.0/src/calsipro/visualisations.py +118 -0
calsipro-0.10.0/PKG-INFO
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: calsipro
|
|
3
|
+
Version: 0.10.0
|
|
4
|
+
Summary:
|
|
5
|
+
Author: Simon Haendeler
|
|
6
|
+
Author-email: simon.ac@haend.de
|
|
7
|
+
Requires-Python: >=3.8,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Requires-Dist: Pillow (>=9.2.0,<10.0.0)
|
|
14
|
+
Requires-Dist: aicsimageio (>=4.9.4,<5.0.0)
|
|
15
|
+
Requires-Dist: aicspylibczi (>=3.0.5)
|
|
16
|
+
Requires-Dist: bokeh (>=3.0.0,<4.0.0)
|
|
17
|
+
Requires-Dist: click (>=8.1.3,<9.0.0)
|
|
18
|
+
Requires-Dist: datashader (>=0.14.2,<0.15.0)
|
|
19
|
+
Requires-Dist: ffmpeg-python (>=0.2.0,<0.3.0)
|
|
20
|
+
Requires-Dist: fsspec (>=2022.7.1)
|
|
21
|
+
Requires-Dist: matplotlib (>=3.5.3,<4.0.0)
|
|
22
|
+
Requires-Dist: numba (>=0.56.0,<0.57.0) ; python_version >= "3.8" and python_version < "3.11"
|
|
23
|
+
Requires-Dist: numba (>=0.57,<0.58) ; python_version >= "3.11"
|
|
24
|
+
Requires-Dist: numpy (>=1.18)
|
|
25
|
+
Requires-Dist: openpyxl (>=3.0.10,<4.0.0)
|
|
26
|
+
Requires-Dist: polars (>=0.16.14,<0.17.0)
|
|
27
|
+
Requires-Dist: pywavelets (>=1.4.1,<2.0.0)
|
|
28
|
+
Requires-Dist: s3fs (>=2023.5.0,<2024.0.0)
|
|
29
|
+
Requires-Dist: scikit-learn (>=1.2.1,<2.0.0)
|
|
30
|
+
Requires-Dist: scipy (>=1.9.0,<2.0.0)
|
|
31
|
+
Requires-Dist: syn-bokeh-helpers (>=0.5.0,<0.6.0)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[tool.ruff]
|
|
2
|
+
line-length = 120
|
|
3
|
+
|
|
4
|
+
[tool.poetry]
|
|
5
|
+
name = "calsipro"
|
|
6
|
+
version = "0.10.0"
|
|
7
|
+
description = ""
|
|
8
|
+
authors = ["Simon Haendeler <simon.ac@haend.de>"]
|
|
9
|
+
|
|
10
|
+
[tool.poetry.scripts]
|
|
11
|
+
calsipro = 'calsipro.cli:cli'
|
|
12
|
+
|
|
13
|
+
[tool.poetry.dependencies]
|
|
14
|
+
python = "^3.8"
|
|
15
|
+
openpyxl = "^3.0.10"
|
|
16
|
+
datashader = "^0.14.2"
|
|
17
|
+
numba = [{version = "^0.56.0", python = ">=3.8,<3.11"}, {version = "^0.57", python=">=3.11"}]
|
|
18
|
+
Pillow = "^9.2.0"
|
|
19
|
+
click = "^8.1.3"
|
|
20
|
+
numpy = ">=1.18"
|
|
21
|
+
scipy = "^1.9.0"
|
|
22
|
+
matplotlib = "^3.5.3"
|
|
23
|
+
bokeh = "^3.0.0"
|
|
24
|
+
aicsimageio = "^4.9.4"
|
|
25
|
+
aicspylibczi = ">=3.0.5"
|
|
26
|
+
fsspec = ">=2022.7.1"
|
|
27
|
+
syn-bokeh-helpers = {version = "^0.5.0", source = "syntonym"}
|
|
28
|
+
pywavelets = "^1.4.1"
|
|
29
|
+
ffmpeg-python = "^0.2.0"
|
|
30
|
+
scikit-learn = "^1.2.1"
|
|
31
|
+
polars = "^0.16.14"
|
|
32
|
+
s3fs = "^2023.5.0"
|
|
33
|
+
|
|
34
|
+
[tool.poetry.dev-dependencies]
|
|
35
|
+
|
|
36
|
+
[tool.poetry.group.dev.dependencies]
|
|
37
|
+
pytest = "^7.2.0"
|
|
38
|
+
mypy = "^0.991"
|
|
39
|
+
popy = {version = "^0.1.1", source = "syntonym"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
[[tool.poetry.source]]
|
|
43
|
+
name = "syntonym"
|
|
44
|
+
url = "http://localhost:8080/"
|
|
45
|
+
default = false
|
|
46
|
+
secondary = false
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["poetry-core>=1.0.0"]
|
|
50
|
+
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import polars as pl
|
|
3
|
+
import scipy.ndimage
|
|
4
|
+
import numba
|
|
5
|
+
|
|
6
|
+
def moving_average(a, n=3):
|
|
7
|
+
if n == 0:
|
|
8
|
+
return a
|
|
9
|
+
ret = np.cumsum(a, axis=2, dtype=float)
|
|
10
|
+
ret[:, :, n:] = ret[:, :, n:] - ret[:, :, :-n]
|
|
11
|
+
return ret[:, :, n - 1:] / n
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize(data):
|
|
15
|
+
m, mm = np.min(data), np.max(data)
|
|
16
|
+
data = (data - m) / (mm - m)
|
|
17
|
+
return data
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _calculate_bf_threshold_and_mask(data, min_size=30, border_size=5):
|
|
21
|
+
|
|
22
|
+
calculation_needed = True
|
|
23
|
+
pick = 1
|
|
24
|
+
while calculation_needed and pick < 80:
|
|
25
|
+
threshold = calculate_threshold(data, pick=pick)
|
|
26
|
+
mask = calculate_mask(data, th=threshold, raw=True, larger=False)
|
|
27
|
+
mask_size = mask.sum()
|
|
28
|
+
if mask_size == 0:
|
|
29
|
+
calculation_needed = False
|
|
30
|
+
elif mask_size < min_size:
|
|
31
|
+
pick += 1
|
|
32
|
+
else:
|
|
33
|
+
calculation_needed = False
|
|
34
|
+
|
|
35
|
+
return mask
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def calculate_mask(t, th=0.25, raw=False, labelling=True, larger=True):
|
|
39
|
+
if not raw:
|
|
40
|
+
t = np.max(t, axis=0)
|
|
41
|
+
t = normalize(t)
|
|
42
|
+
if larger:
|
|
43
|
+
mask = t >= th
|
|
44
|
+
else:
|
|
45
|
+
mask = t <= th
|
|
46
|
+
if not labelling:
|
|
47
|
+
return mask
|
|
48
|
+
image = np.ones(mask.shape)
|
|
49
|
+
image[~mask] = 0
|
|
50
|
+
image[mask] = 1
|
|
51
|
+
label, count = scipy.ndimage.label(image)
|
|
52
|
+
if count == 1:
|
|
53
|
+
return mask
|
|
54
|
+
else:
|
|
55
|
+
sizes = []
|
|
56
|
+
for k in range(1, count+1):
|
|
57
|
+
size = np.sum(label[mask] == k)
|
|
58
|
+
sizes.append(size)
|
|
59
|
+
if len(sizes) > 0:
|
|
60
|
+
biggest = np.argmax(sizes)+1
|
|
61
|
+
else:
|
|
62
|
+
biggest = 1
|
|
63
|
+
return label == biggest
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def calculate_threshold(data, pick=1):
|
|
67
|
+
if np.min(data) == 0:
|
|
68
|
+
offset = 1
|
|
69
|
+
else:
|
|
70
|
+
offset = 0
|
|
71
|
+
try:
|
|
72
|
+
counts, bins = np.histogram(np.log(data+offset), 80)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
d1 = data+offset
|
|
75
|
+
d2 = np.log(d1)
|
|
76
|
+
print('data+offset', d1)
|
|
77
|
+
print('log(data+offset)', d2)
|
|
78
|
+
print('offset', offset)
|
|
79
|
+
print('data min', np.min(data))
|
|
80
|
+
print('data max', np.max(data))
|
|
81
|
+
print('data+offset min', np.min(d1))
|
|
82
|
+
print('data+offset max', np.max(d1))
|
|
83
|
+
print('log(data+offset min)', np.min(d2))
|
|
84
|
+
print('log(data+offset max)', np.max(d2))
|
|
85
|
+
raise e
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
for i in range(len(counts)):
|
|
89
|
+
if counts[i] <= 0:
|
|
90
|
+
counts[i] = 1
|
|
91
|
+
else:
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
for i in range(1, len(counts)):
|
|
95
|
+
if counts[-i] <= 0:
|
|
96
|
+
counts[-i] = 1
|
|
97
|
+
else:
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
freq = np.log(1+counts)
|
|
102
|
+
left_flood = freq.copy()
|
|
103
|
+
right_flood = freq.copy()
|
|
104
|
+
flood = freq.copy()
|
|
105
|
+
|
|
106
|
+
for i in range(1, len(freq)):
|
|
107
|
+
left_flood[i] = max(left_flood[i], left_flood[i-1])
|
|
108
|
+
|
|
109
|
+
for i in list(range(0, len(freq)-1))[::-1]:
|
|
110
|
+
right_flood[i] = max(right_flood[i], right_flood[i+1])
|
|
111
|
+
|
|
112
|
+
for i in range(0, len(freq)):
|
|
113
|
+
flood[i] = min(left_flood[i], right_flood[i])
|
|
114
|
+
|
|
115
|
+
f = flood - freq
|
|
116
|
+
if pick != 1:
|
|
117
|
+
idxs = np.argsort(flood-freq)
|
|
118
|
+
idx = idxs[-pick]
|
|
119
|
+
else:
|
|
120
|
+
idx = np.argmax(flood-freq)
|
|
121
|
+
|
|
122
|
+
rest = freq[idx:]
|
|
123
|
+
low = freq[idx]
|
|
124
|
+
high = np.max(rest)
|
|
125
|
+
|
|
126
|
+
idx_offset = max(0, np.argmax(rest >= (low + (high-low)*0.10))-1)
|
|
127
|
+
idx = idx + idx_offset
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
pixels = np.sum(counts[idx:]) / np.sum(counts)
|
|
131
|
+
if pixels < 0.001:
|
|
132
|
+
return np.max(data)+1
|
|
133
|
+
if 0.999 < pixels:
|
|
134
|
+
return np.max(data)+1
|
|
135
|
+
if idx == 0:
|
|
136
|
+
return np.max(data)+1
|
|
137
|
+
return np.exp(bins[idx+1])-offset
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _find_biggest(mask):
|
|
141
|
+
label, count = scipy.ndimage.label(mask)
|
|
142
|
+
if count == 1:
|
|
143
|
+
return mask
|
|
144
|
+
else:
|
|
145
|
+
sizes = list(np.bincount(label[mask]))
|
|
146
|
+
assert sizes[0] == 0
|
|
147
|
+
sizes = sizes[1:]
|
|
148
|
+
assert len(sizes) == count
|
|
149
|
+
if len(sizes) > 0:
|
|
150
|
+
biggest = np.argmax(sizes)+1
|
|
151
|
+
else:
|
|
152
|
+
biggest = 1
|
|
153
|
+
return label == biggest
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def time_analysis(t, intensity_cutoff=0.5):
|
|
157
|
+
t = t - np.min(t, axis=0).reshape((1, t.shape[1], t.shape[2]))
|
|
158
|
+
t = t / np.max(t, axis=0).reshape((1, t.shape[1], t.shape[2]))
|
|
159
|
+
|
|
160
|
+
time = np.argmax(t >= intensity_cutoff, axis=0)
|
|
161
|
+
return time
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def push_low_pixels(time, mask):
|
|
165
|
+
|
|
166
|
+
m = np.min(time[mask])
|
|
167
|
+
mm = np.max(time[mask])
|
|
168
|
+
|
|
169
|
+
time[~mask] = mm+1
|
|
170
|
+
|
|
171
|
+
for i in range(m, mm+1):
|
|
172
|
+
if np.sum(time == i) < 30:
|
|
173
|
+
time[time == i] = i+1
|
|
174
|
+
m = m+1
|
|
175
|
+
else:
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
for i in range(m, mm+1)[::-1]:
|
|
179
|
+
if np.sum(time == i) < 30:
|
|
180
|
+
time[time == i] = i-1
|
|
181
|
+
mm = mm-1
|
|
182
|
+
else:
|
|
183
|
+
break
|
|
184
|
+
time[~mask] = np.max(time)
|
|
185
|
+
return time
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def find_times(data, mask, times, as_index=True):
|
|
189
|
+
data = data.copy()
|
|
190
|
+
data[~mask] = np.min(data)-1
|
|
191
|
+
if as_index:
|
|
192
|
+
return [(data == time).nonzero() for time in times]
|
|
193
|
+
else:
|
|
194
|
+
return [(data == time) for time in times]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def find_ori_cluster(data, mask, as_index=False):
|
|
198
|
+
cluster_mask = np.zeros(data.shape, dtype=np.bool_)
|
|
199
|
+
ori_time = np.min(data[mask])
|
|
200
|
+
cluster_mask[data == ori_time] = 1
|
|
201
|
+
cluster_mask[~mask] = 0
|
|
202
|
+
label, count = scipy.ndimage.label(cluster_mask, scipy.ndimage.generate_binary_structure(2, 2))
|
|
203
|
+
|
|
204
|
+
if count > 1:
|
|
205
|
+
sizes = []
|
|
206
|
+
for k in range(1, count+1):
|
|
207
|
+
size = np.sum(label[mask] == k)
|
|
208
|
+
sizes.append(size)
|
|
209
|
+
biggest = np.argmax(sizes)+1
|
|
210
|
+
cluster_mask = (label == biggest)
|
|
211
|
+
|
|
212
|
+
if as_index:
|
|
213
|
+
xs, ys = cluster_mask.nonzero()
|
|
214
|
+
return np.array((np.average(xs), np.average(ys))).reshape((2, 1))
|
|
215
|
+
else:
|
|
216
|
+
return cluster_mask
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def calculate_speed(time, mask):
|
|
220
|
+
m, mm = np.min(time[mask]), np.max(time[mask])
|
|
221
|
+
|
|
222
|
+
ori_pos = np.array(find_ori_cluster(time, mask, as_index=True)).reshape((2, 1))
|
|
223
|
+
|
|
224
|
+
timepoints = list(range(m+1, mm+1))
|
|
225
|
+
locations = [np.stack([x, y]) for x, y in find_times(time, mask, timepoints, as_index=True)]
|
|
226
|
+
|
|
227
|
+
dts = [0]
|
|
228
|
+
speeds = [-1]
|
|
229
|
+
ns = [np.sum(time == m)]
|
|
230
|
+
total_speed = 0
|
|
231
|
+
total_ns = 0
|
|
232
|
+
for t, l in zip(timepoints, locations):
|
|
233
|
+
dists = np.sqrt(np.sum((l - ori_pos)**2, axis=0))
|
|
234
|
+
dt = t-m
|
|
235
|
+
dl = np.sum(dists)
|
|
236
|
+
n = dists.shape[0]
|
|
237
|
+
if n == 0:
|
|
238
|
+
continue
|
|
239
|
+
total_speed += dl/dt
|
|
240
|
+
total_ns += n
|
|
241
|
+
speeds.append(dl/(n*dt))
|
|
242
|
+
ns.append(n)
|
|
243
|
+
dts.append(dt)
|
|
244
|
+
if total_ns == 0:
|
|
245
|
+
total_ns = 1
|
|
246
|
+
|
|
247
|
+
r = (pl.DataFrame({'time': np.array(dts, dtype=np.int64),
|
|
248
|
+
'speed': np.array(speeds, dtype=np.float64),
|
|
249
|
+
'n': np.array(ns, dtype=np.int64)}),
|
|
250
|
+
total_speed/total_ns)
|
|
251
|
+
return r
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def calculate_speed_better(time, mask):
|
|
255
|
+
ori_pos = find_ori_cluster(time, mask, as_index=True)
|
|
256
|
+
|
|
257
|
+
x_dist = np.repeat((np.arange(time.shape[0]) - ori_pos[0]).reshape((time.shape[0], 1)), time.shape[1], axis=1)
|
|
258
|
+
y_dist = np.repeat((np.arange(time.shape[1]) - ori_pos[1]).reshape((1, time.shape[1])), time.shape[0], axis=0)
|
|
259
|
+
|
|
260
|
+
dists = np.sqrt(x_dist**2 - y_dist**2)
|
|
261
|
+
|
|
262
|
+
time = time - np.min(time)
|
|
263
|
+
speed = dists / time
|
|
264
|
+
speed[time == 0] = 0
|
|
265
|
+
|
|
266
|
+
return speed
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def tabularize_speed(time, speed, mask):
|
|
270
|
+
m, mm = np.min(time), np.max(time)
|
|
271
|
+
timepoints = list(range(m+1, mm+1))
|
|
272
|
+
|
|
273
|
+
time[mask] = mm+1
|
|
274
|
+
|
|
275
|
+
ns = [np.sum(time == timepoint) for timepoint in timepoints]
|
|
276
|
+
speed = [np.average(speed[time == timepoint]) for timepoint in timepoints]
|
|
277
|
+
|
|
278
|
+
r = (pl.DataFrame({'time': np.array(timepoints, dtype=np.int64),
|
|
279
|
+
'speed': np.array(speed, dtype=np.float64),
|
|
280
|
+
'n': np.array(ns, dtype=np.int64)}),
|
|
281
|
+
np.average(speed[mask]))
|
|
282
|
+
return r
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def reachability(data, threshold=0.02):
|
|
286
|
+
data = normalize(data)
|
|
287
|
+
mask = np.zeros(shape=data.shape, dtype=np.bool_)
|
|
288
|
+
scheduled = np.zeros(shape=data.shape, dtype=np.bool_)
|
|
289
|
+
_reachability(data, scheduled, mask, threshold, 1, 1)
|
|
290
|
+
return mask
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@numba.njit(cache=True)
|
|
294
|
+
def _reachability(data, scheduled, mask, threshold, dx, dy):
|
|
295
|
+
next = []
|
|
296
|
+
x_len, y_len = data.shape
|
|
297
|
+
for y in range(y_len-1):
|
|
298
|
+
x = 0
|
|
299
|
+
mask[x, y] = True
|
|
300
|
+
next.append((x, y))
|
|
301
|
+
scheduled[x, y] = True
|
|
302
|
+
|
|
303
|
+
x = x_len-1
|
|
304
|
+
mask[x, y] = True
|
|
305
|
+
next.append((x, y))
|
|
306
|
+
scheduled[x, y] = True
|
|
307
|
+
|
|
308
|
+
for x in range(x_len-1):
|
|
309
|
+
y = 0
|
|
310
|
+
mask[x, y] = True
|
|
311
|
+
next.append((x, y))
|
|
312
|
+
scheduled[x, y] = True
|
|
313
|
+
|
|
314
|
+
y = y_len-1
|
|
315
|
+
mask[x, y] = True
|
|
316
|
+
next.append((x, y))
|
|
317
|
+
scheduled[x, y] = True
|
|
318
|
+
|
|
319
|
+
while (len(next) != 0):
|
|
320
|
+
x, y = next.pop(0)
|
|
321
|
+
b_v = data[x,y]
|
|
322
|
+
|
|
323
|
+
nx, ny = x+dx, y
|
|
324
|
+
if 0 <= nx < x_len:
|
|
325
|
+
v = data[nx, ny]
|
|
326
|
+
if abs(v-b_v) <= threshold:
|
|
327
|
+
mask[nx, ny] = True
|
|
328
|
+
if not scheduled[nx, ny]:
|
|
329
|
+
scheduled[nx, ny] = True
|
|
330
|
+
next.append((nx, ny))
|
|
331
|
+
|
|
332
|
+
nx, ny = x-dx, y
|
|
333
|
+
if 0 <= nx < x_len:
|
|
334
|
+
v = data[nx, ny]
|
|
335
|
+
if abs(v-b_v) <= threshold:
|
|
336
|
+
mask[nx, ny] = True
|
|
337
|
+
if not scheduled[nx, ny]:
|
|
338
|
+
scheduled[nx, ny] = True
|
|
339
|
+
next.append((nx, ny))
|
|
340
|
+
|
|
341
|
+
nx, ny = x, y+dy
|
|
342
|
+
if 0 <= ny < y_len:
|
|
343
|
+
v = data[nx, ny]
|
|
344
|
+
if abs(v-b_v) <= threshold:
|
|
345
|
+
mask[nx, ny] = True
|
|
346
|
+
if not scheduled[nx, ny]:
|
|
347
|
+
scheduled[nx, ny] = True
|
|
348
|
+
next.append((nx, ny))
|
|
349
|
+
|
|
350
|
+
nx, ny = x, y-dy
|
|
351
|
+
if 0 <= ny < y_len:
|
|
352
|
+
v = data[nx, ny]
|
|
353
|
+
if abs(v-b_v) <= threshold:
|
|
354
|
+
mask[nx, ny] = True
|
|
355
|
+
if not scheduled[nx, ny]:
|
|
356
|
+
scheduled[nx, ny] = True
|
|
357
|
+
next.append((nx, ny))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def calculate_bf_mask(data):
|
|
361
|
+
data = data.copy()
|
|
362
|
+
reachability_mask = reachability(data)
|
|
363
|
+
data[reachability_mask] = np.mean(data[reachability_mask])
|
|
364
|
+
mask = _calculate_bf_threshold_and_mask(data)
|
|
365
|
+
return mask
|