celldetective 1.5.0b9__py3-none-any.whl → 1.5.0b11__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.
- celldetective/_version.py +1 -1
- celldetective/gui/base/list_widget.py +25 -10
- celldetective/gui/event_annotator.py +32 -3
- celldetective/gui/interactions_block.py +11 -2
- celldetective/gui/pair_event_annotator.py +39 -31
- celldetective/gui/settings/_settings_measurements.py +14 -7
- celldetective/gui/settings/_settings_neighborhood.py +1 -0
- celldetective/gui/viewers/contour_viewer.py +14 -2
- celldetective/gui/viewers/spot_detection_viewer.py +14 -0
- celldetective/measure.py +22 -13
- celldetective/utils/data_cleaning.py +7 -3
- celldetective/utils/masks.py +15 -8
- {celldetective-1.5.0b9.dist-info → celldetective-1.5.0b11.dist-info}/METADATA +1 -1
- {celldetective-1.5.0b9.dist-info → celldetective-1.5.0b11.dist-info}/RECORD +22 -20
- tests/gui/test_event_annotator_cleanup.py +310 -0
- tests/gui/test_spot_detection_viewer.py +207 -0
- tests/test_contour_format.py +299 -0
- tests/test_measure.py +318 -129
- {celldetective-1.5.0b9.dist-info → celldetective-1.5.0b11.dist-info}/WHEEL +0 -0
- {celldetective-1.5.0b9.dist-info → celldetective-1.5.0b11.dist-info}/entry_points.txt +0 -0
- {celldetective-1.5.0b9.dist-info → celldetective-1.5.0b11.dist-info}/licenses/LICENSE +0 -0
- {celldetective-1.5.0b9.dist-info → celldetective-1.5.0b11.dist-info}/top_level.txt +0 -0
tests/test_measure.py
CHANGED
|
@@ -1,141 +1,330 @@
|
|
|
1
1
|
import unittest
|
|
2
2
|
import pandas as pd
|
|
3
3
|
import numpy as np
|
|
4
|
-
from celldetective.measure import
|
|
4
|
+
from celldetective.measure import (
|
|
5
|
+
measure_features,
|
|
6
|
+
measure_isotropic_intensity,
|
|
7
|
+
drop_tonal_features,
|
|
8
|
+
)
|
|
5
9
|
|
|
6
|
-
class TestFeatureMeasurement(unittest.TestCase):
|
|
7
|
-
|
|
8
|
-
"""
|
|
9
|
-
To do: test spot detection, fluo normalization and peripheral measurements
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
@classmethod
|
|
13
|
-
def setUpClass(self):
|
|
14
|
-
|
|
15
|
-
# Simple mock data, 100px*100px, one channel, value is one, uniform
|
|
16
|
-
# Two objects in labels map
|
|
17
|
-
|
|
18
|
-
self.frame = np.ones((100,100,1), dtype=float)
|
|
19
|
-
self.labels = np.zeros((100,100), dtype=int)
|
|
20
|
-
self.labels[50:55,50:55] = 1
|
|
21
|
-
self.labels[0:10,0:10] = 2
|
|
22
|
-
|
|
23
|
-
self.feature_measurements = measure_features(
|
|
24
|
-
self.frame,
|
|
25
|
-
self.labels,
|
|
26
|
-
features=['intensity_mean','area',],
|
|
27
|
-
channels=['test_channel']
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
self.feature_measurements_no_image = measure_features(
|
|
31
|
-
None,
|
|
32
|
-
self.labels,
|
|
33
|
-
features=['intensity_mean','area',],
|
|
34
|
-
channels=None
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
self.feature_measurements_no_features = measure_features(
|
|
38
|
-
self.frame,
|
|
39
|
-
self.labels,
|
|
40
|
-
features=None,
|
|
41
|
-
channels=['test_channel'],
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
# With image
|
|
45
|
-
def test_measure_yields_table(self):
|
|
46
|
-
self.assertIsInstance(self.feature_measurements, pd.DataFrame)
|
|
47
|
-
|
|
48
|
-
def test_two_objects(self):
|
|
49
|
-
self.assertEqual(len(self.feature_measurements),2)
|
|
50
|
-
|
|
51
|
-
def test_channel_named_correctly(self):
|
|
52
|
-
self.assertIn('test_channel_mean',list(self.feature_measurements.columns))
|
|
53
10
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
11
|
+
class TestFeatureMeasurement(unittest.TestCase):
|
|
12
|
+
"""
|
|
13
|
+
To do: test spot detection, fluo normalization and peripheral measurements
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def setUpClass(self):
|
|
18
|
+
|
|
19
|
+
# Simple mock data, 100px*100px, one channel, value is one, uniform
|
|
20
|
+
# Two objects in labels map
|
|
21
|
+
|
|
22
|
+
self.frame = np.ones((100, 100, 1), dtype=float)
|
|
23
|
+
self.labels = np.zeros((100, 100), dtype=int)
|
|
24
|
+
self.labels[50:55, 50:55] = 1
|
|
25
|
+
self.labels[0:10, 0:10] = 2
|
|
26
|
+
|
|
27
|
+
self.feature_measurements = measure_features(
|
|
28
|
+
self.frame,
|
|
29
|
+
self.labels,
|
|
30
|
+
features=[
|
|
31
|
+
"intensity_mean",
|
|
32
|
+
"area",
|
|
33
|
+
],
|
|
34
|
+
channels=["test_channel"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self.feature_measurements_no_image = measure_features(
|
|
38
|
+
None,
|
|
39
|
+
self.labels,
|
|
40
|
+
features=[
|
|
41
|
+
"intensity_mean",
|
|
42
|
+
"area",
|
|
43
|
+
],
|
|
44
|
+
channels=None,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self.feature_measurements_no_features = measure_features(
|
|
48
|
+
self.frame,
|
|
49
|
+
self.labels,
|
|
50
|
+
features=None,
|
|
51
|
+
channels=["test_channel"],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# With image
|
|
55
|
+
def test_measure_yields_table(self):
|
|
56
|
+
self.assertIsInstance(self.feature_measurements, pd.DataFrame)
|
|
57
|
+
|
|
58
|
+
def test_two_objects(self):
|
|
59
|
+
self.assertEqual(len(self.feature_measurements), 2)
|
|
60
|
+
|
|
61
|
+
def test_channel_named_correctly(self):
|
|
62
|
+
self.assertIn("test_channel_mean", list(self.feature_measurements.columns))
|
|
63
|
+
|
|
64
|
+
def test_intensity_is_one(self):
|
|
65
|
+
self.assertTrue(
|
|
66
|
+
np.all(
|
|
67
|
+
[
|
|
68
|
+
v == 1.0
|
|
69
|
+
for v in self.feature_measurements["test_channel_mean"].values
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def test_area_first_is_twenty_five(self):
|
|
75
|
+
self.assertEqual(self.feature_measurements["area"].values[0], 25)
|
|
76
|
+
|
|
77
|
+
def test_area_second_is_hundred(self):
|
|
78
|
+
self.assertEqual(self.feature_measurements["area"].values[1], 100)
|
|
79
|
+
|
|
80
|
+
# Without image
|
|
81
|
+
def test_measure_yields_table(self):
|
|
82
|
+
self.assertIsInstance(self.feature_measurements_no_image, pd.DataFrame)
|
|
83
|
+
|
|
84
|
+
def test_two_objects(self):
|
|
85
|
+
self.assertEqual(len(self.feature_measurements_no_image), 2)
|
|
86
|
+
|
|
87
|
+
def test_channel_not_in_table(self):
|
|
88
|
+
self.assertNotIn(
|
|
89
|
+
"test_channel_mean", list(self.feature_measurements_no_image.columns)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# With no features
|
|
93
|
+
def test_only_one_measurement(self):
|
|
94
|
+
cols = list(self.feature_measurements_no_features.columns)
|
|
95
|
+
assert "class_id" in cols and len(cols) == 1
|
|
77
96
|
|
|
78
97
|
|
|
79
98
|
class TestIsotropicMeasurement(unittest.TestCase):
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
Test that isotropic intensity measurements behave as expected on fake image
|
|
102
|
+
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def setUpClass(self):
|
|
107
|
+
|
|
108
|
+
# Simple mock data, 100px*100px, one channel, value is one
|
|
109
|
+
# Square (21*21px) of value 0. in middle
|
|
110
|
+
# Two objects in labels map
|
|
111
|
+
|
|
112
|
+
self.frame = np.ones((100, 100, 1), dtype=float)
|
|
113
|
+
self.frame[40:61, 40:61, 0] = 0.0
|
|
114
|
+
self.positions = pd.DataFrame(
|
|
115
|
+
[
|
|
116
|
+
{
|
|
117
|
+
"TRACK_ID": 0,
|
|
118
|
+
"POSITION_X": 50,
|
|
119
|
+
"POSITION_Y": 50,
|
|
120
|
+
"FRAME": 0,
|
|
121
|
+
"class_id": 0,
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
self.inner_radius = 9
|
|
127
|
+
self.upper_radius = 20
|
|
128
|
+
self.safe_upper_radius = int(21 // 2 * np.sqrt(2)) + 2
|
|
129
|
+
|
|
130
|
+
self.iso_measurements = measure_isotropic_intensity(
|
|
131
|
+
self.positions,
|
|
132
|
+
self.frame,
|
|
133
|
+
channels=["test_channel"],
|
|
134
|
+
intensity_measurement_radii=[self.inner_radius, self.upper_radius],
|
|
135
|
+
operations=["mean"],
|
|
136
|
+
)
|
|
137
|
+
self.iso_measurements_ring = measure_isotropic_intensity(
|
|
138
|
+
self.positions,
|
|
139
|
+
self.frame,
|
|
140
|
+
channels=["test_channel"],
|
|
141
|
+
intensity_measurement_radii=[
|
|
142
|
+
[self.safe_upper_radius, self.safe_upper_radius + 3]
|
|
143
|
+
],
|
|
144
|
+
operations=["mean"],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def test_measure_yields_table(self):
|
|
148
|
+
self.assertIsInstance(self.iso_measurements, pd.DataFrame)
|
|
149
|
+
|
|
150
|
+
def test_intensity_zero_in_small_circle(self):
|
|
151
|
+
self.assertEqual(
|
|
152
|
+
self.iso_measurements[
|
|
153
|
+
f"test_channel_circle_{self.inner_radius}_mean"
|
|
154
|
+
].values[0],
|
|
155
|
+
0.0,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def test_intensity_greater_than_zero_in_intermediate_circle(self):
|
|
159
|
+
self.assertGreater(
|
|
160
|
+
self.iso_measurements[
|
|
161
|
+
f"test_channel_circle_{self.upper_radius}_mean"
|
|
162
|
+
].values[0],
|
|
163
|
+
0.0,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def test_ring_measurement_avoids_zero(self):
|
|
167
|
+
self.assertEqual(
|
|
168
|
+
self.iso_measurements[
|
|
169
|
+
f"test_channel_ring_{self.safe_upper_radius}_{self.safe_upper_radius+3}_mean"
|
|
170
|
+
].values[0],
|
|
171
|
+
1.0,
|
|
172
|
+
)
|
|
80
173
|
|
|
81
|
-
"""
|
|
82
|
-
|
|
83
|
-
Test that isotropic intensity measurements behave as expected on fake image
|
|
84
|
-
|
|
85
|
-
"""
|
|
86
|
-
|
|
87
|
-
@classmethod
|
|
88
|
-
def setUpClass(self):
|
|
89
|
-
|
|
90
|
-
# Simple mock data, 100px*100px, one channel, value is one
|
|
91
|
-
# Square (21*21px) of value 0. in middle
|
|
92
|
-
# Two objects in labels map
|
|
93
|
-
|
|
94
|
-
self.frame = np.ones((100,100,1), dtype=float)
|
|
95
|
-
self.frame[40:61,40:61,0] = 0.
|
|
96
|
-
self.positions = pd.DataFrame([{'TRACK_ID': 0, 'POSITION_X': 50, 'POSITION_Y': 50, 'FRAME': 0, 'class_id': 0}])
|
|
97
|
-
|
|
98
|
-
self.inner_radius = 9
|
|
99
|
-
self.upper_radius = 20
|
|
100
|
-
self.safe_upper_radius = int(21//2*np.sqrt(2))+2
|
|
101
|
-
|
|
102
|
-
self.iso_measurements = measure_isotropic_intensity(self.positions,
|
|
103
|
-
self.frame,
|
|
104
|
-
channels=['test_channel'],
|
|
105
|
-
intensity_measurement_radii=[self.inner_radius, self.upper_radius],
|
|
106
|
-
operations = ['mean'],
|
|
107
|
-
)
|
|
108
|
-
self.iso_measurements_ring = measure_isotropic_intensity(
|
|
109
|
-
self.positions,
|
|
110
|
-
self.frame,
|
|
111
|
-
channels=['test_channel'],
|
|
112
|
-
intensity_measurement_radii=[[self.safe_upper_radius, self.safe_upper_radius+3]],
|
|
113
|
-
operations = ['mean'],
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def test_measure_yields_table(self):
|
|
118
|
-
self.assertIsInstance(self.iso_measurements, pd.DataFrame)
|
|
119
|
-
|
|
120
|
-
def test_intensity_zero_in_small_circle(self):
|
|
121
|
-
self.assertEqual(self.iso_measurements[f'test_channel_circle_{self.inner_radius}_mean'].values[0],0.)
|
|
122
|
-
|
|
123
|
-
def test_intensity_greater_than_zero_in_intermediate_circle(self):
|
|
124
|
-
self.assertGreater(self.iso_measurements[f'test_channel_circle_{self.upper_radius}_mean'].values[0],0.)
|
|
125
|
-
|
|
126
|
-
def test_ring_measurement_avoids_zero(self):
|
|
127
|
-
self.assertEqual(self.iso_measurements[f'test_channel_ring_{self.safe_upper_radius}_{self.safe_upper_radius+3}_mean'].values[0],1.0)
|
|
128
174
|
|
|
129
175
|
class TestDropTonal(unittest.TestCase):
|
|
130
176
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
177
|
+
@classmethod
|
|
178
|
+
def setUpClass(self):
|
|
179
|
+
self.features = ["area", "intensity_mean", "intensity_max"]
|
|
180
|
+
|
|
181
|
+
def test_drop_tonal(self):
|
|
182
|
+
self.features_processed = drop_tonal_features(self.features)
|
|
183
|
+
self.assertEqual(self.features_processed, ["area"])
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class TestNumpyArrayHandling(unittest.TestCase):
|
|
187
|
+
"""
|
|
188
|
+
Regression test for bug where passing channels as numpy array caused AttributeError.
|
|
189
|
+
Fix: 'numpy.ndarray' object has no attribute 'index'
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def setUpClass(self):
|
|
194
|
+
self.frame = np.ones((100, 100, 2), dtype=float)
|
|
195
|
+
self.labels = np.zeros((100, 100), dtype=int)
|
|
196
|
+
self.labels[50:60, 50:60] = 1
|
|
197
|
+
|
|
198
|
+
# KEY: Pass channels as numpy array to trigger the potential bug
|
|
199
|
+
self.channels = np.array(["channel_1", "channel_2"])
|
|
200
|
+
|
|
201
|
+
def test_measure_features_with_numpy_channels(self):
|
|
202
|
+
"""
|
|
203
|
+
Test that measure_features works when channels is a numpy array.
|
|
204
|
+
Prevents regression of AttributeError: 'numpy.ndarray' object has no attribute 'index'
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
df = measure_features(
|
|
208
|
+
self.frame,
|
|
209
|
+
self.labels,
|
|
210
|
+
features=["intensity_mean"],
|
|
211
|
+
channels=self.channels,
|
|
212
|
+
)
|
|
213
|
+
self.assertIsInstance(df, pd.DataFrame)
|
|
214
|
+
self.assertIn("channel_1_mean", df.columns)
|
|
215
|
+
except AttributeError as e:
|
|
216
|
+
self.fail(f"measure_features failed with numpy array channels: {e}")
|
|
217
|
+
|
|
218
|
+
def test_spot_detection_with_numpy_channels_match(self):
|
|
219
|
+
"""
|
|
220
|
+
Test spot detection logic with numpy array channels.
|
|
221
|
+
The bug also appeared in spot detection channel matching.
|
|
222
|
+
"""
|
|
223
|
+
spot_opts = {
|
|
224
|
+
"channel": "channel_1", # Matches one of the channels
|
|
225
|
+
"diameter": 5,
|
|
226
|
+
"threshold": 0.1,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
# Should not raise AttributeError
|
|
231
|
+
df = measure_features(
|
|
232
|
+
self.frame,
|
|
233
|
+
self.labels,
|
|
234
|
+
channels=self.channels,
|
|
235
|
+
spot_detection=spot_opts,
|
|
236
|
+
)
|
|
237
|
+
self.assertIsInstance(df, pd.DataFrame)
|
|
238
|
+
except AttributeError as e:
|
|
239
|
+
self.fail(f"Spot detection failed with numpy array channels: {e}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class TestExtraPropertiesNotAutoIncluded(unittest.TestCase):
|
|
243
|
+
"""
|
|
244
|
+
Regression test for bug where ALL extra_properties functions containing 'intensity'
|
|
245
|
+
were being included in edge measurements, instead of only user-requested ones.
|
|
246
|
+
|
|
247
|
+
Bug: In measure_features(), when border_dist was set, the code used `extra`
|
|
248
|
+
(ALL available extra_properties functions) instead of `requested_extra_names`
|
|
249
|
+
(only user-requested ones). This caused unwanted measurements and warnings.
|
|
250
|
+
|
|
251
|
+
Fix: Changed to use only requested_extra_names in intensity_features list.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
@classmethod
|
|
255
|
+
def setUpClass(cls):
|
|
256
|
+
"""Create simple test data."""
|
|
257
|
+
cls.frame = np.ones((100, 100, 1), dtype=float)
|
|
258
|
+
cls.labels = np.zeros((100, 100), dtype=int)
|
|
259
|
+
cls.labels[40:60, 40:60] = 1 # 20x20 square
|
|
260
|
+
|
|
261
|
+
def test_border_dist_does_not_include_unrequested_extra_props(self):
|
|
262
|
+
"""
|
|
263
|
+
Test that edge measurements only include requested features,
|
|
264
|
+
not all extra_properties containing 'intensity'.
|
|
265
|
+
|
|
266
|
+
Before fix: Would include ~30+ extra_properties functions with 'intensity'.
|
|
267
|
+
After fix: Only includes explicitly requested features.
|
|
268
|
+
"""
|
|
269
|
+
result = measure_features(
|
|
270
|
+
self.frame,
|
|
271
|
+
self.labels,
|
|
272
|
+
features=["intensity_mean"], # Only request mean intensity
|
|
273
|
+
channels=["test"],
|
|
274
|
+
border_dist=[5],
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
self.assertIsInstance(result, pd.DataFrame)
|
|
278
|
+
|
|
279
|
+
# Get all column names related to edge/slice measurements
|
|
280
|
+
edge_columns = [c for c in result.columns if "edge" in c or "slice" in c]
|
|
281
|
+
|
|
282
|
+
# Should only have requested intensity_mean edge measurement, not all extra_properties
|
|
283
|
+
# Before the fix, this would include many unwanted columns like:
|
|
284
|
+
# - mean_dark_intensity_*
|
|
285
|
+
# - intensity_percentile_*
|
|
286
|
+
# - etc.
|
|
287
|
+
|
|
288
|
+
# Count intensity-related edge columns - should be minimal (just what we requested)
|
|
289
|
+
intensity_edge_cols = [
|
|
290
|
+
c for c in edge_columns if "intensity" in c.lower() or "mean" in c.lower()
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
# We requested only intensity_mean, so should have at most 1-2 edge columns per channel
|
|
294
|
+
# (the mean intensity for the edge region)
|
|
295
|
+
# Before the fix, this would be 30+ columns
|
|
296
|
+
self.assertLess(
|
|
297
|
+
len(intensity_edge_cols),
|
|
298
|
+
10,
|
|
299
|
+
f"Too many intensity edge columns found ({len(intensity_edge_cols)}). "
|
|
300
|
+
f"This suggests unrequested extra_properties are being included. "
|
|
301
|
+
f"Columns: {intensity_edge_cols}",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def test_no_features_requested_only_adds_mean(self):
|
|
305
|
+
"""
|
|
306
|
+
Test that when no intensity features are requested, only basic mean is added.
|
|
307
|
+
Should not include all extra_properties.
|
|
308
|
+
"""
|
|
309
|
+
result = measure_features(
|
|
310
|
+
self.frame,
|
|
311
|
+
self.labels,
|
|
312
|
+
features=["area"], # Non-intensity feature only
|
|
313
|
+
channels=["test"],
|
|
314
|
+
border_dist=[5],
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
self.assertIsInstance(result, pd.DataFrame)
|
|
318
|
+
|
|
319
|
+
# Should have area column
|
|
320
|
+
self.assertIn("area", result.columns)
|
|
321
|
+
|
|
322
|
+
# Edge columns should only have basic intensity (auto-added for edges)
|
|
323
|
+
edge_columns = [c for c in result.columns if "edge" in c or "slice" in c]
|
|
324
|
+
|
|
325
|
+
# Should have minimal edge measurements (just auto-added mean for edge measurement)
|
|
326
|
+
self.assertLess(len(edge_columns), 10, f"Too many edge columns: {edge_columns}")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
if __name__ == "__main__":
|
|
330
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|