senoquant 1.0.0b2__py3-none-any.whl → 1.0.0b4__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.
- senoquant/__init__.py +6 -2
- senoquant/_reader.py +1 -1
- senoquant/_widget.py +9 -1
- senoquant/reader/core.py +201 -18
- senoquant/tabs/__init__.py +2 -0
- senoquant/tabs/batch/backend.py +76 -27
- senoquant/tabs/batch/frontend.py +127 -25
- senoquant/tabs/quantification/features/marker/dialog.py +26 -6
- senoquant/tabs/quantification/features/marker/export.py +97 -24
- senoquant/tabs/quantification/features/marker/rows.py +2 -2
- senoquant/tabs/quantification/features/spots/dialog.py +41 -11
- senoquant/tabs/quantification/features/spots/export.py +163 -10
- senoquant/tabs/quantification/frontend.py +2 -2
- senoquant/tabs/segmentation/frontend.py +46 -9
- senoquant/tabs/segmentation/models/cpsam/model.py +1 -1
- senoquant/tabs/segmentation/models/default_2d/model.py +22 -77
- senoquant/tabs/segmentation/models/default_3d/model.py +8 -74
- senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tools/create_zip_contents.py +0 -0
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/probe.py +13 -13
- senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/stardist_libs.py +171 -0
- senoquant/tabs/spots/frontend.py +96 -5
- senoquant/tabs/spots/models/rmp/details.json +3 -9
- senoquant/tabs/spots/models/rmp/model.py +341 -266
- senoquant/tabs/spots/models/ufish/details.json +32 -0
- senoquant/tabs/spots/models/ufish/model.py +327 -0
- senoquant/tabs/spots/ufish_utils/__init__.py +13 -0
- senoquant/tabs/spots/ufish_utils/core.py +387 -0
- senoquant/tabs/visualization/__init__.py +1 -0
- senoquant/tabs/visualization/backend.py +306 -0
- senoquant/tabs/visualization/frontend.py +1113 -0
- senoquant/tabs/visualization/plots/__init__.py +80 -0
- senoquant/tabs/visualization/plots/base.py +152 -0
- senoquant/tabs/visualization/plots/double_expression.py +187 -0
- senoquant/tabs/visualization/plots/spatialplot.py +156 -0
- senoquant/tabs/visualization/plots/umap.py +140 -0
- senoquant/utils.py +1 -1
- senoquant-1.0.0b4.dist-info/METADATA +162 -0
- {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/RECORD +53 -30
- {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/top_level.txt +1 -0
- ufish/__init__.py +1 -0
- ufish/api.py +778 -0
- ufish/model/__init__.py +0 -0
- ufish/model/loss.py +62 -0
- ufish/model/network/__init__.py +0 -0
- ufish/model/network/spot_learn.py +50 -0
- ufish/model/network/ufish_net.py +204 -0
- ufish/model/train.py +175 -0
- ufish/utils/__init__.py +0 -0
- ufish/utils/img.py +418 -0
- ufish/utils/log.py +8 -0
- ufish/utils/spot_calling.py +115 -0
- senoquant/tabs/spots/models/udwt/details.json +0 -103
- senoquant/tabs/spots/models/udwt/model.py +0 -482
- senoquant-1.0.0b2.dist-info/METADATA +0 -193
- {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/WHEEL +0 -0
- {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/entry_points.txt +0 -0
- {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/licenses/LICENSE +0 -0
|
@@ -53,6 +53,17 @@ def make_probe_image(
|
|
|
53
53
|
if model_path is None or input_layout is None:
|
|
54
54
|
return probe
|
|
55
55
|
|
|
56
|
+
div_by = None
|
|
57
|
+
if div_by_cache is not None:
|
|
58
|
+
div_by = div_by_cache.get(model_path)
|
|
59
|
+
if div_by is None:
|
|
60
|
+
try:
|
|
61
|
+
div_by = infer_div_by(model_path, ndim=image.ndim)
|
|
62
|
+
except Exception:
|
|
63
|
+
div_by = None
|
|
64
|
+
if div_by_cache is not None and div_by is not None:
|
|
65
|
+
div_by_cache[model_path] = div_by
|
|
66
|
+
|
|
56
67
|
patterns = None
|
|
57
68
|
if valid_size_cache is not None:
|
|
58
69
|
patterns = valid_size_cache.get(model_path)
|
|
@@ -68,24 +79,13 @@ def make_probe_image(
|
|
|
68
79
|
if valid_size_cache is not None:
|
|
69
80
|
valid_size_cache[model_path] = patterns
|
|
70
81
|
|
|
71
|
-
div_by = None
|
|
72
|
-
if div_by_cache is not None:
|
|
73
|
-
div_by = div_by_cache.get(model_path)
|
|
74
|
-
if div_by is None:
|
|
75
|
-
try:
|
|
76
|
-
div_by = infer_div_by(model_path, ndim=image.ndim)
|
|
77
|
-
except Exception:
|
|
78
|
-
div_by = None
|
|
79
|
-
if div_by_cache is not None and div_by is not None:
|
|
80
|
-
div_by_cache[model_path] = div_by
|
|
81
|
-
|
|
82
82
|
desired = list(probe.shape)
|
|
83
83
|
if patterns:
|
|
84
84
|
desired = [
|
|
85
85
|
max(1, snap_size(int(size), patterns[axis]))
|
|
86
86
|
for axis, size in enumerate(desired)
|
|
87
87
|
]
|
|
88
|
-
|
|
88
|
+
if div_by:
|
|
89
89
|
desired = [
|
|
90
90
|
max(int(d), (int(size) // int(d)) * int(d)) if d else int(size)
|
|
91
91
|
for size, d in zip(desired, div_by)
|
|
@@ -97,4 +97,4 @@ def make_probe_image(
|
|
|
97
97
|
pads = [(0, max(0, d - s)) for s, d in zip(probe.shape, desired)]
|
|
98
98
|
if any(pad_after > 0 for _, pad_after in pads):
|
|
99
99
|
probe = np.pad(probe, pads, mode="reflect")
|
|
100
|
-
return probe
|
|
100
|
+
return probe
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Helpers for locating and loading StarDist compiled extensions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.machinery
|
|
6
|
+
import importlib.util
|
|
7
|
+
import sys
|
|
8
|
+
import types
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ensure_stardist_libs( # noqa: C901, PLR0912, PLR0915
|
|
13
|
+
utils_root: Path,
|
|
14
|
+
stardist_pkg: str,
|
|
15
|
+
) -> tuple[bool, bool]:
|
|
16
|
+
"""Ensure StarDist compiled extensions can be imported.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
utils_root : pathlib.Path
|
|
21
|
+
Root path to the ``stardist_onnx_utils`` package.
|
|
22
|
+
stardist_pkg : str
|
|
23
|
+
Fully-qualified package name for the vendored StarDist package.
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
tuple[bool, bool]
|
|
28
|
+
Tuple of ``(has_2d, has_3d)`` indicating which compiled
|
|
29
|
+
extensions are available.
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
csbdeep_root = utils_root / "_csbdeep"
|
|
33
|
+
if csbdeep_root.exists():
|
|
34
|
+
csbdeep_path = str(csbdeep_root)
|
|
35
|
+
if csbdeep_path not in sys.path:
|
|
36
|
+
sys.path.insert(0, csbdeep_path)
|
|
37
|
+
|
|
38
|
+
if stardist_pkg not in sys.modules:
|
|
39
|
+
pkg = types.ModuleType(stardist_pkg)
|
|
40
|
+
pkg.__path__ = [str(utils_root / "_stardist")]
|
|
41
|
+
sys.modules[stardist_pkg] = pkg
|
|
42
|
+
|
|
43
|
+
base_pkg = f"{stardist_pkg}.lib"
|
|
44
|
+
lib_dirs: list[Path] = []
|
|
45
|
+
seen_dirs: set[Path] = set()
|
|
46
|
+
|
|
47
|
+
def _append_if_exists(path: Path) -> None:
|
|
48
|
+
if not path.exists():
|
|
49
|
+
return
|
|
50
|
+
resolved = path.resolve()
|
|
51
|
+
if resolved in seen_dirs:
|
|
52
|
+
return
|
|
53
|
+
seen_dirs.add(resolved)
|
|
54
|
+
lib_dirs.append(resolved)
|
|
55
|
+
|
|
56
|
+
_append_if_exists(utils_root / "_stardist" / "lib")
|
|
57
|
+
for entry in list(sys.path):
|
|
58
|
+
if not entry:
|
|
59
|
+
continue
|
|
60
|
+
try:
|
|
61
|
+
legacy_candidate = (
|
|
62
|
+
Path(entry)
|
|
63
|
+
/ "senoquant"
|
|
64
|
+
/ "tabs"
|
|
65
|
+
/ "segmentation"
|
|
66
|
+
/ "stardist_onnx_utils"
|
|
67
|
+
/ "_stardist"
|
|
68
|
+
/ "lib"
|
|
69
|
+
)
|
|
70
|
+
ext_candidate = Path(entry) / "senoquant_stardist_ext" / "lib"
|
|
71
|
+
except (TypeError, ValueError, OSError):
|
|
72
|
+
continue
|
|
73
|
+
_append_if_exists(legacy_candidate)
|
|
74
|
+
_append_if_exists(ext_candidate)
|
|
75
|
+
|
|
76
|
+
if base_pkg in sys.modules:
|
|
77
|
+
pkg = sys.modules[base_pkg]
|
|
78
|
+
pkg.__path__ = [str(p) for p in lib_dirs]
|
|
79
|
+
else:
|
|
80
|
+
pkg = types.ModuleType(base_pkg)
|
|
81
|
+
pkg.__path__ = [str(p) for p in lib_dirs]
|
|
82
|
+
sys.modules[base_pkg] = pkg
|
|
83
|
+
|
|
84
|
+
for module_name in (f"{base_pkg}.stardist2d", f"{base_pkg}.stardist3d"):
|
|
85
|
+
try:
|
|
86
|
+
spec = importlib.util.find_spec(module_name)
|
|
87
|
+
except (ImportError, AttributeError, ValueError):
|
|
88
|
+
spec = None
|
|
89
|
+
if spec and spec.origin:
|
|
90
|
+
try:
|
|
91
|
+
candidate = Path(spec.origin).parent
|
|
92
|
+
except (TypeError, OSError):
|
|
93
|
+
candidate = None
|
|
94
|
+
if candidate is not None and candidate.exists():
|
|
95
|
+
lib_dirs.append(candidate)
|
|
96
|
+
|
|
97
|
+
mod2d = f"{base_pkg}.stardist2d"
|
|
98
|
+
mod3d = f"{base_pkg}.stardist3d"
|
|
99
|
+
|
|
100
|
+
def _stub(*_args: object, **_kwargs: object) -> None:
|
|
101
|
+
msg = "StarDist compiled ops are unavailable."
|
|
102
|
+
raise RuntimeError(msg)
|
|
103
|
+
|
|
104
|
+
def _module_available(module_name: str) -> bool:
|
|
105
|
+
module = sys.modules.get(module_name)
|
|
106
|
+
if module is not None:
|
|
107
|
+
return getattr(module, "__file__", None) is not None
|
|
108
|
+
try:
|
|
109
|
+
spec = importlib.util.find_spec(module_name)
|
|
110
|
+
except (ImportError, AttributeError, ValueError):
|
|
111
|
+
return False
|
|
112
|
+
return bool(spec and spec.origin)
|
|
113
|
+
|
|
114
|
+
def _try_load_dll(module_name: str, dll_path: Path) -> bool:
|
|
115
|
+
try:
|
|
116
|
+
loader = importlib.machinery.ExtensionFileLoader(
|
|
117
|
+
module_name,
|
|
118
|
+
str(dll_path),
|
|
119
|
+
)
|
|
120
|
+
spec = importlib.util.spec_from_file_location(
|
|
121
|
+
module_name,
|
|
122
|
+
str(dll_path),
|
|
123
|
+
loader=loader,
|
|
124
|
+
)
|
|
125
|
+
if spec is None or spec.loader is None:
|
|
126
|
+
return False
|
|
127
|
+
module = importlib.util.module_from_spec(spec)
|
|
128
|
+
spec.loader.exec_module(module)
|
|
129
|
+
sys.modules[module_name] = module
|
|
130
|
+
except (ImportError, OSError, AttributeError, ValueError):
|
|
131
|
+
return False
|
|
132
|
+
else:
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
if not _module_available(mod2d):
|
|
136
|
+
for lib_dir in lib_dirs:
|
|
137
|
+
candidates = sorted(lib_dir.glob("stardist2d*.dll"))
|
|
138
|
+
if candidates and _try_load_dll(mod2d, candidates[0]):
|
|
139
|
+
break
|
|
140
|
+
if not _module_available(mod3d):
|
|
141
|
+
for lib_dir in lib_dirs:
|
|
142
|
+
candidates = sorted(lib_dir.glob("stardist3d*.dll"))
|
|
143
|
+
if candidates and _try_load_dll(mod3d, candidates[0]):
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
has_2d = _module_available(mod2d)
|
|
147
|
+
has_3d = _module_available(mod3d)
|
|
148
|
+
|
|
149
|
+
if has_2d and mod2d in sys.modules and getattr(
|
|
150
|
+
sys.modules[mod2d], "__file__", None,
|
|
151
|
+
) is None:
|
|
152
|
+
del sys.modules[mod2d]
|
|
153
|
+
if not has_2d and mod2d not in sys.modules:
|
|
154
|
+
module = types.ModuleType(mod2d)
|
|
155
|
+
module.c_star_dist = _stub # type: ignore[attr-defined]
|
|
156
|
+
module.c_non_max_suppression_inds_old = _stub # type: ignore[attr-defined]
|
|
157
|
+
module.c_non_max_suppression_inds = _stub # type: ignore[attr-defined]
|
|
158
|
+
sys.modules[mod2d] = module
|
|
159
|
+
|
|
160
|
+
if has_3d and mod3d in sys.modules and getattr(
|
|
161
|
+
sys.modules[mod3d], "__file__", None,
|
|
162
|
+
) is None:
|
|
163
|
+
del sys.modules[mod3d]
|
|
164
|
+
if not has_3d and mod3d not in sys.modules:
|
|
165
|
+
module = types.ModuleType(mod3d)
|
|
166
|
+
module.c_star_dist3d = _stub # type: ignore[attr-defined]
|
|
167
|
+
module.c_polyhedron_to_label = _stub # type: ignore[attr-defined]
|
|
168
|
+
module.c_non_max_suppression_inds = _stub # type: ignore[attr-defined]
|
|
169
|
+
sys.modules[mod3d] = module
|
|
170
|
+
|
|
171
|
+
return has_2d, has_3d
|
senoquant/tabs/spots/frontend.py
CHANGED
|
@@ -118,7 +118,7 @@ class SpotsTab(QWidget):
|
|
|
118
118
|
backend : SpotsBackend or None
|
|
119
119
|
Backend instance used to discover and load detectors.
|
|
120
120
|
napari_viewer : object or None
|
|
121
|
-
|
|
121
|
+
napari viewer used to populate layer choices.
|
|
122
122
|
"""
|
|
123
123
|
|
|
124
124
|
def __init__(
|
|
@@ -491,11 +491,15 @@ class SpotsTab(QWidget):
|
|
|
491
491
|
detector = self._backend.get_detector(detector_name)
|
|
492
492
|
layer = self._get_layer_by_name(self._layer_combo.currentText())
|
|
493
493
|
settings = self._collect_settings()
|
|
494
|
+
|
|
495
|
+
def run_detector() -> dict:
|
|
496
|
+
return detector.run(layer=layer, settings=settings)
|
|
497
|
+
|
|
494
498
|
self._start_background_run(
|
|
495
499
|
run_button=self._run_button,
|
|
496
500
|
run_text="Run",
|
|
497
501
|
detector_name=detector_name,
|
|
498
|
-
run_callable=
|
|
502
|
+
run_callable=run_detector,
|
|
499
503
|
on_success=lambda result: self._handle_run_result(
|
|
500
504
|
layer, detector_name, result
|
|
501
505
|
),
|
|
@@ -647,16 +651,103 @@ class SpotsTab(QWidget):
|
|
|
647
651
|
if mask is not None:
|
|
648
652
|
filtered_mask = self._apply_size_filter(mask)
|
|
649
653
|
self._add_labels_layer(layer, filtered_mask, detector_name)
|
|
654
|
+
debug_images = result.get("debug_images")
|
|
655
|
+
if isinstance(debug_images, dict):
|
|
656
|
+
self._add_debug_image_layers(layer, detector_name, debug_images)
|
|
650
657
|
|
|
651
658
|
def _add_labels_layer(self, source_layer, mask, detector_name: str) -> None:
|
|
652
659
|
"""Add a labels layer for the detector mask."""
|
|
653
660
|
if self._viewer is None or source_layer is None:
|
|
654
661
|
return
|
|
655
662
|
name = self._spot_label_name(source_layer, detector_name)
|
|
656
|
-
|
|
657
|
-
|
|
663
|
+
source_metadata = getattr(source_layer, "metadata", {})
|
|
664
|
+
merged_metadata: dict[str, object] = {}
|
|
665
|
+
if isinstance(source_metadata, dict):
|
|
666
|
+
merged_metadata.update(source_metadata)
|
|
667
|
+
merged_metadata["task"] = "spots"
|
|
668
|
+
|
|
669
|
+
labels_layer = None
|
|
670
|
+
if Labels is not None and hasattr(self._viewer, "add_layer"):
|
|
671
|
+
# Add a fully configured Labels layer object to avoid name-based lookup.
|
|
672
|
+
labels_layer = Labels(
|
|
673
|
+
mask,
|
|
674
|
+
name=name,
|
|
675
|
+
metadata=merged_metadata,
|
|
676
|
+
)
|
|
677
|
+
added_layer = self._viewer.add_layer(labels_layer)
|
|
678
|
+
if added_layer is not None:
|
|
679
|
+
labels_layer = added_layer
|
|
680
|
+
elif hasattr(self._viewer, "add_labels"):
|
|
681
|
+
try:
|
|
682
|
+
labels_layer = self._viewer.add_labels(
|
|
683
|
+
mask,
|
|
684
|
+
name=name,
|
|
685
|
+
metadata=merged_metadata,
|
|
686
|
+
)
|
|
687
|
+
except TypeError:
|
|
688
|
+
labels_layer = self._viewer.add_labels(mask, name=name)
|
|
689
|
+
|
|
690
|
+
if labels_layer is None:
|
|
691
|
+
return
|
|
692
|
+
|
|
693
|
+
layer_metadata = getattr(labels_layer, "metadata", {})
|
|
694
|
+
if isinstance(layer_metadata, dict):
|
|
695
|
+
merged_metadata.update(layer_metadata)
|
|
696
|
+
merged_metadata["task"] = "spots"
|
|
697
|
+
labels_layer.metadata = merged_metadata
|
|
658
698
|
labels_layer.contour = 1
|
|
659
699
|
|
|
700
|
+
def _add_debug_image_layers(
|
|
701
|
+
self,
|
|
702
|
+
source_layer,
|
|
703
|
+
detector_name: str,
|
|
704
|
+
debug_images: dict,
|
|
705
|
+
) -> None:
|
|
706
|
+
"""Add debug image layers emitted by a detector run."""
|
|
707
|
+
if self._viewer is None:
|
|
708
|
+
return
|
|
709
|
+
source_name = getattr(source_layer, "name", "") if source_layer is not None else ""
|
|
710
|
+
source_name = source_name.strip() if isinstance(source_name, str) else ""
|
|
711
|
+
|
|
712
|
+
for debug_key, debug_image in debug_images.items():
|
|
713
|
+
if debug_image is None:
|
|
714
|
+
continue
|
|
715
|
+
image_data = np.asarray(debug_image)
|
|
716
|
+
if image_data.size == 0:
|
|
717
|
+
continue
|
|
718
|
+
layer_name = f"{detector_name}_{debug_key}"
|
|
719
|
+
if source_name:
|
|
720
|
+
layer_name = f"{source_name}_{layer_name}"
|
|
721
|
+
if layer_name in self._viewer.layers:
|
|
722
|
+
self._viewer.layers.remove(layer_name)
|
|
723
|
+
|
|
724
|
+
metadata = {"task": "spots", "debug": True}
|
|
725
|
+
image_layer = None
|
|
726
|
+
if Image is not None and hasattr(self._viewer, "add_layer"):
|
|
727
|
+
image_layer = Image(
|
|
728
|
+
image_data,
|
|
729
|
+
name=layer_name,
|
|
730
|
+
metadata=metadata,
|
|
731
|
+
)
|
|
732
|
+
added_layer = self._viewer.add_layer(image_layer)
|
|
733
|
+
if added_layer is not None:
|
|
734
|
+
image_layer = added_layer
|
|
735
|
+
elif hasattr(self._viewer, "add_image"):
|
|
736
|
+
try:
|
|
737
|
+
image_layer = self._viewer.add_image(
|
|
738
|
+
image_data,
|
|
739
|
+
name=layer_name,
|
|
740
|
+
metadata=metadata,
|
|
741
|
+
)
|
|
742
|
+
except TypeError:
|
|
743
|
+
image_layer = self._viewer.add_image(
|
|
744
|
+
image_data,
|
|
745
|
+
name=layer_name,
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
if image_layer is not None:
|
|
749
|
+
image_layer.visible = True
|
|
750
|
+
|
|
660
751
|
def _apply_size_filter(self, mask: np.ndarray) -> np.ndarray:
|
|
661
752
|
"""Filter spots by size based on min/max settings.
|
|
662
753
|
|
|
@@ -722,7 +813,7 @@ class SpotsTab(QWidget):
|
|
|
722
813
|
Parameters
|
|
723
814
|
----------
|
|
724
815
|
layer : object or None
|
|
725
|
-
|
|
816
|
+
napari layer to validate.
|
|
726
817
|
label : str
|
|
727
818
|
User-facing label for notifications.
|
|
728
819
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rmp",
|
|
3
|
-
"description": "
|
|
3
|
+
"description": "RMP spot detector implementation",
|
|
4
4
|
"version": "0.1.0",
|
|
5
|
-
"order":
|
|
5
|
+
"order": 0,
|
|
6
6
|
"settings": [
|
|
7
7
|
{
|
|
8
8
|
"key": "denoising_kernel_length",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"key": "extraction_kernel_length",
|
|
18
18
|
"label": "Extraction kernel length",
|
|
19
19
|
"type": "int",
|
|
20
|
-
"min":
|
|
20
|
+
"min": 3,
|
|
21
21
|
"max": 9999,
|
|
22
22
|
"default": 10
|
|
23
23
|
},
|
|
@@ -50,12 +50,6 @@
|
|
|
50
50
|
"label": "Enable denoising",
|
|
51
51
|
"type": "bool",
|
|
52
52
|
"default": true
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
"key": "use_3d",
|
|
56
|
-
"label": "3D",
|
|
57
|
-
"type": "bool",
|
|
58
|
-
"default": false
|
|
59
53
|
}
|
|
60
54
|
]
|
|
61
55
|
}
|