driftlessmap 1.1.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.
- driftlessmap/__init__.py +16 -0
- driftlessmap/__main__.py +5 -0
- driftlessmap/about.py +24 -0
- driftlessmap/allen_downloader.py +908 -0
- driftlessmap/app.py +8085 -0
- driftlessmap/atlas_downloader.py +370 -0
- driftlessmap/atlas_loader.py +526 -0
- driftlessmap/atlas_processor.py +784 -0
- driftlessmap/atlas_transform.py +111 -0
- driftlessmap/atlas_view.py +1435 -0
- driftlessmap/cell_detection.py +27 -0
- driftlessmap/coordinate_validation.py +19 -0
- driftlessmap/czi_reader.py +236 -0
- driftlessmap/data/WHS_atlas_labels.pkl +0 -0
- driftlessmap/data/allen_mice_atlas_labels.pkl +0 -0
- driftlessmap/data/atlas_labels.pkl +0 -0
- driftlessmap/data/query.csv +1328 -0
- driftlessmap/download_utils.py +85 -0
- driftlessmap/herbs.png +0 -0
- driftlessmap/icons/backward.svg +15 -0
- driftlessmap/icons/dot.svg +11 -0
- driftlessmap/icons/down-arrow.svg +10 -0
- driftlessmap/icons/fast_backward.svg +14 -0
- driftlessmap/icons/fast_forward.svg +14 -0
- driftlessmap/icons/forward.svg +14 -0
- driftlessmap/icons/layers/add.png +0 -0
- driftlessmap/icons/layers/ai.png +0 -0
- driftlessmap/icons/layers/eye_off.png +0 -0
- driftlessmap/icons/layers/eye_on.png +0 -0
- driftlessmap/icons/layers/eye_white.png +0 -0
- driftlessmap/icons/layers/hi.png +0 -0
- driftlessmap/icons/layers/trash.png +0 -0
- driftlessmap/icons/object.svg +22 -0
- driftlessmap/icons/sidebar/add.png +0 -0
- driftlessmap/icons/sidebar/atlascontrol.png +0 -0
- driftlessmap/icons/sidebar/atlascontrol.svg +32 -0
- driftlessmap/icons/sidebar/bnd.svg +14 -0
- driftlessmap/icons/sidebar/c_section.png +0 -0
- driftlessmap/icons/sidebar/c_section2.png +0 -0
- driftlessmap/icons/sidebar/cell.svg +24 -0
- driftlessmap/icons/sidebar/check.svg +9 -0
- driftlessmap/icons/sidebar/compare.svg +18 -0
- driftlessmap/icons/sidebar/contour.svg +14 -0
- driftlessmap/icons/sidebar/h_section.png +0 -0
- driftlessmap/icons/sidebar/info.svg +14 -0
- driftlessmap/icons/sidebar/layers.png +0 -0
- driftlessmap/icons/sidebar/layers.svg +23 -0
- driftlessmap/icons/sidebar/line.svg +10 -0
- driftlessmap/icons/sidebar/link.svg +18 -0
- driftlessmap/icons/sidebar/link_off.svg +18 -0
- driftlessmap/icons/sidebar/merge.svg +11 -0
- driftlessmap/icons/sidebar/object.svg +13 -0
- driftlessmap/icons/sidebar/probe.png +0 -0
- driftlessmap/icons/sidebar/probe.svg +23 -0
- driftlessmap/icons/sidebar/rotation_horizontal.svg +14 -0
- driftlessmap/icons/sidebar/rotation_vertical.svg +14 -0
- driftlessmap/icons/sidebar/s_section.png +0 -0
- driftlessmap/icons/sidebar/tool.png +0 -0
- driftlessmap/icons/sidebar/tool.svg +19 -0
- driftlessmap/icons/sidebar/trash.png +0 -0
- driftlessmap/icons/sidebar/tree_checked.svg +10 -0
- driftlessmap/icons/sidebar/treeview.png +0 -0
- driftlessmap/icons/sidebar/treeview.svg +10 -0
- driftlessmap/icons/sidebar/treeview2.png +0 -0
- driftlessmap/icons/sidebar/virus.svg +24 -0
- driftlessmap/icons/tdown.svg +16 -0
- driftlessmap/icons/toolbar/accept.svg +18 -0
- driftlessmap/icons/toolbar/accept2.svg +24 -0
- driftlessmap/icons/toolbar/aim.svg +44 -0
- driftlessmap/icons/toolbar/aim_not.svg +28 -0
- driftlessmap/icons/toolbar/anchor.svg +14 -0
- driftlessmap/icons/toolbar/anticlockwise_rotation.svg +12 -0
- driftlessmap/icons/toolbar/atlas_icon.png +0 -0
- driftlessmap/icons/toolbar/boundary_register.svg +18 -0
- driftlessmap/icons/toolbar/cancel.svg +32 -0
- driftlessmap/icons/toolbar/cell_select.svg +22 -0
- driftlessmap/icons/toolbar/cell_select_not.svg +12 -0
- driftlessmap/icons/toolbar/check.svg +9 -0
- driftlessmap/icons/toolbar/clockwise_rotation.svg +11 -0
- driftlessmap/icons/toolbar/closed_path.svg +16 -0
- driftlessmap/icons/toolbar/closed_path2 copy.svg +19 -0
- driftlessmap/icons/toolbar/closed_path2.svg +20 -0
- driftlessmap/icons/toolbar/eraser.png +0 -0
- driftlessmap/icons/toolbar/eraser.svg +19 -0
- driftlessmap/icons/toolbar/eye.svg +17 -0
- driftlessmap/icons/toolbar/eye_closed.svg +19 -0
- driftlessmap/icons/toolbar/fill.svg +12 -0
- driftlessmap/icons/toolbar/gps.svg +15 -0
- driftlessmap/icons/toolbar/h_flip.png +0 -0
- driftlessmap/icons/toolbar/handle.png +0 -0
- driftlessmap/icons/toolbar/image_icon.png +0 -0
- driftlessmap/icons/toolbar/image_icon.svg +25 -0
- driftlessmap/icons/toolbar/info.svg +14 -0
- driftlessmap/icons/toolbar/inpart.png +0 -0
- driftlessmap/icons/toolbar/inpart.svg +17 -0
- driftlessmap/icons/toolbar/lasso.png +0 -0
- driftlessmap/icons/toolbar/lasso.svg +14 -0
- driftlessmap/icons/toolbar/left90.png +0 -0
- driftlessmap/icons/toolbar/line.svg +10 -0
- driftlessmap/icons/toolbar/line_sites.svg +11 -0
- driftlessmap/icons/toolbar/linear_silicon.png +0 -0
- driftlessmap/icons/toolbar/linear_silicon.svg +88 -0
- driftlessmap/icons/toolbar/list.svg +14 -0
- driftlessmap/icons/toolbar/location.svg +14 -0
- driftlessmap/icons/toolbar/magic-wand.svg +16 -0
- driftlessmap/icons/toolbar/magic_white.png +0 -0
- driftlessmap/icons/toolbar/mask.svg +10 -0
- driftlessmap/icons/toolbar/match.svg +20 -0
- driftlessmap/icons/toolbar/matchbnd.svg +18 -0
- driftlessmap/icons/toolbar/matching.svg +36 -0
- driftlessmap/icons/toolbar/merge.svg +11 -0
- driftlessmap/icons/toolbar/move_down.png +0 -0
- driftlessmap/icons/toolbar/move_left.png +0 -0
- driftlessmap/icons/toolbar/move_right.png +0 -0
- driftlessmap/icons/toolbar/move_up.png +0 -0
- driftlessmap/icons/toolbar/moving.png +0 -0
- driftlessmap/icons/toolbar/multi-probe.svg +119 -0
- driftlessmap/icons/toolbar/multi_pencil.svg +23 -0
- driftlessmap/icons/toolbar/multi_probe.png +0 -0
- driftlessmap/icons/toolbar/object.svg +13 -0
- driftlessmap/icons/toolbar/open_path.svg +22 -0
- driftlessmap/icons/toolbar/outpart.png +0 -0
- driftlessmap/icons/toolbar/outpart.svg +26 -0
- driftlessmap/icons/toolbar/pencil.png +0 -0
- driftlessmap/icons/toolbar/pencil.svg +19 -0
- driftlessmap/icons/toolbar/plasso.png +0 -0
- driftlessmap/icons/toolbar/probe.svg +19 -0
- driftlessmap/icons/toolbar/radar.svg +32 -0
- driftlessmap/icons/toolbar/rotation.svg +45 -0
- driftlessmap/icons/toolbar/rotation_clockwise.svg +18 -0
- driftlessmap/icons/toolbar/rotation_counter_clockwise.svg +18 -0
- driftlessmap/icons/toolbar/ruler.svg +27 -0
- driftlessmap/icons/toolbar/separate_sites.svg +13 -0
- driftlessmap/icons/toolbar/single_pencil.svg +16 -0
- driftlessmap/icons/toolbar/toa.svg +11 -0
- driftlessmap/icons/toolbar/toa_delete.svg +36 -0
- driftlessmap/icons/toolbar/toaa.svg +11 -0
- driftlessmap/icons/toolbar/toh.svg +11 -0
- driftlessmap/icons/toolbar/toh_delete.svg +36 -0
- driftlessmap/icons/toolbar/tohh.svg +11 -0
- driftlessmap/icons/toolbar/trans.png +0 -0
- driftlessmap/icons/toolbar/trans.svg +16 -0
- driftlessmap/icons/toolbar/triangulation.svg +10 -0
- driftlessmap/icons/toolbar/two_window.png +0 -0
- driftlessmap/icons/toolbar/unmerge.svg +16 -0
- driftlessmap/icons/toolbar/v_flip.png +0 -0
- driftlessmap/icons/toolbar/virus.svg +24 -0
- driftlessmap/icons/toolbar/virus_register.svg +31 -0
- driftlessmap/icons/toolbar/vis2d.svg +29 -0
- driftlessmap/icons/toolbar/window2.png +0 -0
- driftlessmap/icons/toolbar/window3.png +0 -0
- driftlessmap/icons/toolbar/window4.png +0 -0
- driftlessmap/icons/tree_close.svg +14 -0
- driftlessmap/icons/tree_open.svg +14 -0
- driftlessmap/icons/up-arrow.svg +10 -0
- driftlessmap/image_curves.py +579 -0
- driftlessmap/image_reader.py +186 -0
- driftlessmap/image_stacks.py +359 -0
- driftlessmap/image_view.py +646 -0
- driftlessmap/images_reader.py +5 -0
- driftlessmap/label_tree.py +278 -0
- driftlessmap/layer_validation.py +25 -0
- driftlessmap/layers_control.py +554 -0
- driftlessmap/main_window.ui +883 -0
- driftlessmap/movable_points.py +254 -0
- driftlessmap/obj_items.py +122 -0
- driftlessmap/object_control.py +1762 -0
- driftlessmap/persistence.py +376 -0
- driftlessmap/popup_message.py +16 -0
- driftlessmap/probe_csv.py +319 -0
- driftlessmap/probe_reconstruction.py +409 -0
- driftlessmap/probe_utiles.py +1442 -0
- driftlessmap/qss/atlas_view_group_box.qss +15 -0
- driftlessmap/qss/box_label.qss +7 -0
- driftlessmap/qss/channel_selector.qss +19 -0
- driftlessmap/qss/color_combo.qss +121 -0
- driftlessmap/qss/decor_label.qss +9 -0
- driftlessmap/qss/dialogs.qss +11 -0
- driftlessmap/qss/hidden_line_edit.qss +5 -0
- driftlessmap/qss/label_tree.qss +123 -0
- driftlessmap/qss/main_window.qss +243 -0
- driftlessmap/qss/menu_bar.qss +42 -0
- driftlessmap/qss/multi_handle_slider.qss +14 -0
- driftlessmap/qss/obj_ctrl_bottom_button.qss +29 -0
- driftlessmap/qss/object_text_button.qss +19 -0
- driftlessmap/qss/page_control.qss +36 -0
- driftlessmap/qss/rotation_button.qss +9 -0
- driftlessmap/qss/side_bar.qss +23 -0
- driftlessmap/qss/spinbox.qss +106 -0
- driftlessmap/qss/tabs.qss +211 -0
- driftlessmap/qss/tool_bar.qss +77 -0
- driftlessmap/resources.py +34 -0
- driftlessmap/roi_analysis.py +375 -0
- driftlessmap/run_driftlessmap.py +4 -0
- driftlessmap/slice_stacks.py +268 -0
- driftlessmap/slice_validation.py +20 -0
- driftlessmap/toolbox.py +569 -0
- driftlessmap/triangulation.py +517 -0
- driftlessmap/triangulation_points.py +108 -0
- driftlessmap/user_settings.py +92 -0
- driftlessmap/uuuuuu.py +1168 -0
- driftlessmap/version.py +3 -0
- driftlessmap/widgets_utils.py +140 -0
- driftlessmap/wtiles.py +715 -0
- driftlessmap-1.1.0.dist-info/METADATA +186 -0
- driftlessmap-1.1.0.dist-info/RECORD +210 -0
- driftlessmap-1.1.0.dist-info/WHEEL +5 -0
- driftlessmap-1.1.0.dist-info/entry_points.txt +2 -0
- driftlessmap-1.1.0.dist-info/licenses/LICENSE.txt +23 -0
- driftlessmap-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Validated, reproducible piecewise-affine registration for DriftlessMap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
from scipy.spatial import Delaunay, QhullError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TRIANGULATION_SCHEMA_VERSION = 1
|
|
11
|
+
_DUPLICATE_DISTANCE_PX = 0.5
|
|
12
|
+
_MIN_DOUBLE_AREA_PX2 = 1.0
|
|
13
|
+
_BARYCENTRIC_TOLERANCE = 1e-6
|
|
14
|
+
_MAP_TILE_ROWS = 256
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TriangulationError(ValueError):
|
|
18
|
+
"""Raised when paired landmarks cannot define a safe registration."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _as_points(points, name):
|
|
22
|
+
points = np.asarray(points, dtype=float)
|
|
23
|
+
if points.ndim != 2 or points.shape[1:] != (2,):
|
|
24
|
+
raise TriangulationError(
|
|
25
|
+
"{} landmarks must have shape (N, 2).".format(name)
|
|
26
|
+
)
|
|
27
|
+
if not np.all(np.isfinite(points)):
|
|
28
|
+
raise TriangulationError(
|
|
29
|
+
"{} landmarks contain non-finite coordinates.".format(name)
|
|
30
|
+
)
|
|
31
|
+
return points
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _as_shape(shape, name):
|
|
35
|
+
try:
|
|
36
|
+
shape = tuple(int(value) for value in shape[:2])
|
|
37
|
+
except (TypeError, ValueError, IndexError):
|
|
38
|
+
raise TriangulationError(
|
|
39
|
+
"{} image shape must contain height and width.".format(name)
|
|
40
|
+
) from None
|
|
41
|
+
if len(shape) != 2 or any(value <= 0 for value in shape):
|
|
42
|
+
raise TriangulationError(
|
|
43
|
+
"{} image shape must contain positive height and width.".format(name)
|
|
44
|
+
)
|
|
45
|
+
return shape
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate_bounds(points, shape, name):
|
|
49
|
+
height, width = shape
|
|
50
|
+
valid = (
|
|
51
|
+
(points[:, 0] >= 0)
|
|
52
|
+
& (points[:, 0] <= width - 1)
|
|
53
|
+
& (points[:, 1] >= 0)
|
|
54
|
+
& (points[:, 1] <= height - 1)
|
|
55
|
+
)
|
|
56
|
+
if not np.all(valid):
|
|
57
|
+
bad = int(np.flatnonzero(~valid)[0])
|
|
58
|
+
raise TriangulationError(
|
|
59
|
+
"{} landmark {} is outside the image.".format(name, bad + 1)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _validate_duplicates(points, name):
|
|
64
|
+
if len(points) < 2:
|
|
65
|
+
return
|
|
66
|
+
deltas = points[:, None, :] - points[None, :, :]
|
|
67
|
+
distances = np.linalg.norm(deltas, axis=2)
|
|
68
|
+
np.fill_diagonal(distances, np.inf)
|
|
69
|
+
duplicate = np.argwhere(distances < _DUPLICATE_DISTANCE_PX)
|
|
70
|
+
if duplicate.size:
|
|
71
|
+
first, second = duplicate[0]
|
|
72
|
+
raise TriangulationError(
|
|
73
|
+
"{} landmarks {} and {} are duplicates or too close together.".format(
|
|
74
|
+
name, int(first) + 1, int(second) + 1
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _canonical_simplices(points):
|
|
80
|
+
try:
|
|
81
|
+
simplices = Delaunay(points).simplices
|
|
82
|
+
except QhullError as exc:
|
|
83
|
+
raise TriangulationError(
|
|
84
|
+
"Landmarks are collinear or cannot form a Delaunay mesh."
|
|
85
|
+
) from exc
|
|
86
|
+
simplices = np.sort(np.asarray(simplices, dtype=np.int32), axis=1)
|
|
87
|
+
order = np.lexsort(
|
|
88
|
+
(simplices[:, 2], simplices[:, 1], simplices[:, 0])
|
|
89
|
+
)
|
|
90
|
+
return simplices[order]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _validate_simplices(simplices, point_count):
|
|
94
|
+
simplices = np.asarray(simplices, dtype=np.int32)
|
|
95
|
+
if simplices.ndim != 2 or simplices.shape[1:] != (3,):
|
|
96
|
+
raise TriangulationError("Triangle connectivity must have shape (T, 3).")
|
|
97
|
+
if not len(simplices):
|
|
98
|
+
raise TriangulationError("The landmark mesh contains no triangles.")
|
|
99
|
+
if np.any(simplices < 0) or np.any(simplices >= point_count):
|
|
100
|
+
raise TriangulationError("Triangle connectivity references a missing landmark.")
|
|
101
|
+
if np.any(
|
|
102
|
+
np.diff(np.sort(simplices, axis=1), axis=1) == 0
|
|
103
|
+
):
|
|
104
|
+
raise TriangulationError("A triangle references the same landmark twice.")
|
|
105
|
+
unique_rows = np.unique(np.sort(simplices, axis=1), axis=0)
|
|
106
|
+
if len(unique_rows) != len(simplices):
|
|
107
|
+
raise TriangulationError("Triangle connectivity contains duplicates.")
|
|
108
|
+
return simplices
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _signed_double_area(triangles):
|
|
112
|
+
edge_1 = triangles[:, 1] - triangles[:, 0]
|
|
113
|
+
edge_2 = triangles[:, 2] - triangles[:, 0]
|
|
114
|
+
return edge_1[:, 0] * edge_2[:, 1] - edge_1[:, 1] * edge_2[:, 0]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _minimum_angles(triangles):
|
|
118
|
+
result = np.zeros(len(triangles), dtype=float)
|
|
119
|
+
for index, triangle in enumerate(triangles):
|
|
120
|
+
sides = np.array(
|
|
121
|
+
[
|
|
122
|
+
np.linalg.norm(triangle[1] - triangle[2]),
|
|
123
|
+
np.linalg.norm(triangle[0] - triangle[2]),
|
|
124
|
+
np.linalg.norm(triangle[0] - triangle[1]),
|
|
125
|
+
],
|
|
126
|
+
dtype=float,
|
|
127
|
+
)
|
|
128
|
+
if np.any(sides <= 0):
|
|
129
|
+
result[index] = 0.0
|
|
130
|
+
continue
|
|
131
|
+
cosines = np.array(
|
|
132
|
+
[
|
|
133
|
+
(sides[1] ** 2 + sides[2] ** 2 - sides[0] ** 2)
|
|
134
|
+
/ (2 * sides[1] * sides[2]),
|
|
135
|
+
(sides[0] ** 2 + sides[2] ** 2 - sides[1] ** 2)
|
|
136
|
+
/ (2 * sides[0] * sides[2]),
|
|
137
|
+
(sides[0] ** 2 + sides[1] ** 2 - sides[2] ** 2)
|
|
138
|
+
/ (2 * sides[0] * sides[1]),
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
result[index] = float(
|
|
142
|
+
np.min(np.degrees(np.arccos(np.clip(cosines, -1.0, 1.0))))
|
|
143
|
+
)
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _affine_condition_numbers(source_triangles, target_triangles):
|
|
148
|
+
conditions = np.full(len(source_triangles), np.inf, dtype=float)
|
|
149
|
+
for index, (source, target) in enumerate(
|
|
150
|
+
zip(source_triangles, target_triangles)
|
|
151
|
+
):
|
|
152
|
+
source_edges = (source[1:] - source[0]).T
|
|
153
|
+
target_edges = (target[1:] - target[0]).T
|
|
154
|
+
try:
|
|
155
|
+
linear = target_edges @ np.linalg.inv(source_edges)
|
|
156
|
+
except np.linalg.LinAlgError:
|
|
157
|
+
continue
|
|
158
|
+
singular_values = np.linalg.svd(linear, compute_uv=False)
|
|
159
|
+
if singular_values[-1] > np.finfo(float).eps:
|
|
160
|
+
conditions[index] = float(
|
|
161
|
+
singular_values[0] / singular_values[-1]
|
|
162
|
+
)
|
|
163
|
+
return conditions
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _mesh_quality(atlas_points, histology_points, simplices):
|
|
167
|
+
atlas_triangles = atlas_points[simplices]
|
|
168
|
+
histology_triangles = histology_points[simplices]
|
|
169
|
+
atlas_area = _signed_double_area(atlas_triangles)
|
|
170
|
+
histology_area = _signed_double_area(histology_triangles)
|
|
171
|
+
degenerate = (
|
|
172
|
+
(np.abs(atlas_area) < _MIN_DOUBLE_AREA_PX2)
|
|
173
|
+
| (np.abs(histology_area) < _MIN_DOUBLE_AREA_PX2)
|
|
174
|
+
)
|
|
175
|
+
folded = (atlas_area * histology_area < 0) & ~degenerate
|
|
176
|
+
|
|
177
|
+
area_ratio = np.full(len(simplices), np.nan, dtype=float)
|
|
178
|
+
usable = np.abs(histology_area) >= _MIN_DOUBLE_AREA_PX2
|
|
179
|
+
area_ratio[usable] = (
|
|
180
|
+
np.abs(atlas_area[usable]) / np.abs(histology_area[usable])
|
|
181
|
+
)
|
|
182
|
+
finite_ratio = area_ratio[np.isfinite(area_ratio) & (area_ratio > 0)]
|
|
183
|
+
median_ratio = float(np.median(finite_ratio)) if finite_ratio.size else 1.0
|
|
184
|
+
normalized_area_ratio = area_ratio / median_ratio
|
|
185
|
+
|
|
186
|
+
minimum_angle = np.minimum(
|
|
187
|
+
_minimum_angles(atlas_triangles),
|
|
188
|
+
_minimum_angles(histology_triangles),
|
|
189
|
+
)
|
|
190
|
+
condition = _affine_condition_numbers(
|
|
191
|
+
histology_triangles, atlas_triangles
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
severity = np.zeros(len(simplices), dtype=np.uint8)
|
|
195
|
+
warning = (
|
|
196
|
+
(minimum_angle < 15.0)
|
|
197
|
+
| (condition > 6.0)
|
|
198
|
+
| (normalized_area_ratio < 0.25)
|
|
199
|
+
| (normalized_area_ratio > 4.0)
|
|
200
|
+
)
|
|
201
|
+
severe = (
|
|
202
|
+
degenerate
|
|
203
|
+
| folded
|
|
204
|
+
| (minimum_angle < 5.0)
|
|
205
|
+
| (condition > 20.0)
|
|
206
|
+
| (normalized_area_ratio < 0.05)
|
|
207
|
+
| (normalized_area_ratio > 20.0)
|
|
208
|
+
)
|
|
209
|
+
severity[warning] = 1
|
|
210
|
+
severity[severe] = 2
|
|
211
|
+
|
|
212
|
+
finite_condition = condition[np.isfinite(condition)]
|
|
213
|
+
summary = {
|
|
214
|
+
"triangle_count": int(len(simplices)),
|
|
215
|
+
"folded_count": int(np.count_nonzero(folded)),
|
|
216
|
+
"degenerate_count": int(np.count_nonzero(degenerate)),
|
|
217
|
+
"warning_count": int(np.count_nonzero(severity == 1)),
|
|
218
|
+
"severe_count": int(np.count_nonzero(severity == 2)),
|
|
219
|
+
"review_count": int(np.count_nonzero(severity > 0)),
|
|
220
|
+
"minimum_angle_deg": (
|
|
221
|
+
float(np.min(minimum_angle)) if len(minimum_angle) else 0.0
|
|
222
|
+
),
|
|
223
|
+
"maximum_anisotropy": (
|
|
224
|
+
float(np.max(finite_condition))
|
|
225
|
+
if finite_condition.size
|
|
226
|
+
else float("inf")
|
|
227
|
+
),
|
|
228
|
+
"minimum_normalized_area": (
|
|
229
|
+
float(np.nanmin(normalized_area_ratio))
|
|
230
|
+
if np.any(np.isfinite(normalized_area_ratio))
|
|
231
|
+
else float("nan")
|
|
232
|
+
),
|
|
233
|
+
"maximum_normalized_area": (
|
|
234
|
+
float(np.nanmax(normalized_area_ratio))
|
|
235
|
+
if np.any(np.isfinite(normalized_area_ratio))
|
|
236
|
+
else float("nan")
|
|
237
|
+
),
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
"atlas_signed_double_area": atlas_area,
|
|
241
|
+
"histology_signed_double_area": histology_area,
|
|
242
|
+
"normalized_area_ratio": normalized_area_ratio,
|
|
243
|
+
"minimum_angle_deg": minimum_angle,
|
|
244
|
+
"anisotropy": condition,
|
|
245
|
+
"folded": folded,
|
|
246
|
+
"degenerate": degenerate,
|
|
247
|
+
"severity": severity,
|
|
248
|
+
"summary": summary,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def build_piecewise_affine_registration(
|
|
253
|
+
atlas_points,
|
|
254
|
+
histology_points,
|
|
255
|
+
*,
|
|
256
|
+
atlas_shape,
|
|
257
|
+
histology_shape,
|
|
258
|
+
simplices=None,
|
|
259
|
+
allow_unsafe=False,
|
|
260
|
+
):
|
|
261
|
+
"""Create one topology shared by both registration directions.
|
|
262
|
+
|
|
263
|
+
``allow_unsafe`` is intended only for live mesh visualization. Transfers
|
|
264
|
+
keep the default strict behavior and reject collapsed or folded triangles.
|
|
265
|
+
"""
|
|
266
|
+
atlas_points = _as_points(atlas_points, "Atlas")
|
|
267
|
+
histology_points = _as_points(histology_points, "Histology")
|
|
268
|
+
atlas_shape = _as_shape(atlas_shape, "Atlas")
|
|
269
|
+
histology_shape = _as_shape(histology_shape, "Histology")
|
|
270
|
+
|
|
271
|
+
if len(atlas_points) != len(histology_points):
|
|
272
|
+
raise TriangulationError(
|
|
273
|
+
"Atlas and histology must contain the same number of paired landmarks."
|
|
274
|
+
)
|
|
275
|
+
if len(atlas_points) < 3:
|
|
276
|
+
raise TriangulationError(
|
|
277
|
+
"At least three paired landmarks are required."
|
|
278
|
+
)
|
|
279
|
+
_validate_bounds(atlas_points, atlas_shape, "Atlas")
|
|
280
|
+
_validate_bounds(histology_points, histology_shape, "Histology")
|
|
281
|
+
_validate_duplicates(atlas_points, "Atlas")
|
|
282
|
+
_validate_duplicates(histology_points, "Histology")
|
|
283
|
+
|
|
284
|
+
if simplices is None:
|
|
285
|
+
simplices = _canonical_simplices(atlas_points)
|
|
286
|
+
else:
|
|
287
|
+
simplices = _validate_simplices(simplices, len(atlas_points))
|
|
288
|
+
quality = _mesh_quality(atlas_points, histology_points, simplices)
|
|
289
|
+
summary = quality["summary"]
|
|
290
|
+
errors = []
|
|
291
|
+
if summary["degenerate_count"]:
|
|
292
|
+
errors.append(
|
|
293
|
+
"{} triangle(s) collapse to a line or a point.".format(
|
|
294
|
+
summary["degenerate_count"]
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
if summary["folded_count"]:
|
|
298
|
+
errors.append(
|
|
299
|
+
"{} triangle(s) are folded. Move the corresponding landmarks "
|
|
300
|
+
"until the red triangles disappear.".format(summary["folded_count"])
|
|
301
|
+
)
|
|
302
|
+
if errors and not allow_unsafe:
|
|
303
|
+
raise TriangulationError(" ".join(errors))
|
|
304
|
+
|
|
305
|
+
warnings = []
|
|
306
|
+
if summary["warning_count"] or summary["severe_count"]:
|
|
307
|
+
warnings.append(
|
|
308
|
+
"{} triangle(s) have high stretch or narrow angles.".format(
|
|
309
|
+
summary["warning_count"] + summary["severe_count"]
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
return {
|
|
313
|
+
"schema_version": TRIANGULATION_SCHEMA_VERSION,
|
|
314
|
+
"method": "shared-atlas-delaunay-piecewise-affine",
|
|
315
|
+
"atlas_points": atlas_points,
|
|
316
|
+
"histology_points": histology_points,
|
|
317
|
+
"atlas_shape": atlas_shape,
|
|
318
|
+
"histology_shape": histology_shape,
|
|
319
|
+
"simplices": np.asarray(simplices, dtype=np.int32),
|
|
320
|
+
"quality": quality,
|
|
321
|
+
"warnings": warnings,
|
|
322
|
+
"errors": errors,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def registration_summary_text(registration):
|
|
327
|
+
summary = registration["quality"]["summary"]
|
|
328
|
+
text = (
|
|
329
|
+
"{triangle_count} triangles | {folded_count} folded | "
|
|
330
|
+
"{review_count} warning/review | min angle {minimum_angle_deg:.1f}° | "
|
|
331
|
+
"max anisotropy {maximum_anisotropy:.1f}×"
|
|
332
|
+
).format(**summary)
|
|
333
|
+
return text
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def triangle_colors(registration):
|
|
337
|
+
"""Return RGB colors for healthy, warning, and severe triangles."""
|
|
338
|
+
severity = registration["quality"]["severity"]
|
|
339
|
+
palette = np.array(
|
|
340
|
+
[
|
|
341
|
+
[60, 190, 90],
|
|
342
|
+
[240, 180, 30],
|
|
343
|
+
[225, 65, 65],
|
|
344
|
+
],
|
|
345
|
+
dtype=np.uint8,
|
|
346
|
+
)
|
|
347
|
+
return palette[severity]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _registration_spaces(registration, direction):
|
|
351
|
+
if direction == "histology_to_atlas":
|
|
352
|
+
return (
|
|
353
|
+
registration["histology_points"],
|
|
354
|
+
registration["atlas_points"],
|
|
355
|
+
registration["atlas_shape"],
|
|
356
|
+
)
|
|
357
|
+
if direction == "atlas_to_histology":
|
|
358
|
+
return (
|
|
359
|
+
registration["atlas_points"],
|
|
360
|
+
registration["histology_points"],
|
|
361
|
+
registration["histology_shape"],
|
|
362
|
+
)
|
|
363
|
+
raise ValueError(
|
|
364
|
+
"Direction must be 'histology_to_atlas' or 'atlas_to_histology'."
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _barycentric(points, triangle):
|
|
369
|
+
matrix = (triangle[1:] - triangle[0]).T
|
|
370
|
+
try:
|
|
371
|
+
inverse = np.linalg.inv(matrix)
|
|
372
|
+
except np.linalg.LinAlgError:
|
|
373
|
+
return np.full((len(points), 3), np.nan, dtype=float)
|
|
374
|
+
uv = (points - triangle[0]) @ inverse.T
|
|
375
|
+
return np.column_stack((1.0 - uv[:, 0] - uv[:, 1], uv))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def dense_inverse_map(registration, direction):
|
|
379
|
+
"""Return OpenCV maps from every destination pixel back to the source."""
|
|
380
|
+
source_points, destination_points, destination_shape = (
|
|
381
|
+
_registration_spaces(registration, direction)
|
|
382
|
+
)
|
|
383
|
+
height, width = destination_shape
|
|
384
|
+
map_x = np.full((height, width), -1.0, dtype=np.float32)
|
|
385
|
+
map_y = np.full((height, width), -1.0, dtype=np.float32)
|
|
386
|
+
assigned = np.zeros((height, width), dtype=bool)
|
|
387
|
+
|
|
388
|
+
for simplex in registration["simplices"]:
|
|
389
|
+
destination_triangle = destination_points[simplex]
|
|
390
|
+
source_triangle = source_points[simplex]
|
|
391
|
+
x_min = max(0, int(np.floor(np.min(destination_triangle[:, 0]))))
|
|
392
|
+
x_max = min(
|
|
393
|
+
width - 1, int(np.ceil(np.max(destination_triangle[:, 0])))
|
|
394
|
+
)
|
|
395
|
+
y_min = max(0, int(np.floor(np.min(destination_triangle[:, 1]))))
|
|
396
|
+
y_max = min(
|
|
397
|
+
height - 1, int(np.ceil(np.max(destination_triangle[:, 1])))
|
|
398
|
+
)
|
|
399
|
+
if x_max < x_min or y_max < y_min:
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
for tile_y_min in range(y_min, y_max + 1, _MAP_TILE_ROWS):
|
|
403
|
+
tile_y_max = min(y_max, tile_y_min + _MAP_TILE_ROWS - 1)
|
|
404
|
+
grid_y, grid_x = np.mgrid[
|
|
405
|
+
tile_y_min : tile_y_max + 1, x_min : x_max + 1
|
|
406
|
+
]
|
|
407
|
+
coordinates = np.column_stack((grid_x.ravel(), grid_y.ravel()))
|
|
408
|
+
weights = _barycentric(coordinates, destination_triangle)
|
|
409
|
+
inside = np.all(weights >= -_BARYCENTRIC_TOLERANCE, axis=1)
|
|
410
|
+
if not np.any(inside):
|
|
411
|
+
continue
|
|
412
|
+
destination_y = grid_y.ravel()[inside]
|
|
413
|
+
destination_x = grid_x.ravel()[inside]
|
|
414
|
+
new_pixels = ~assigned[destination_y, destination_x]
|
|
415
|
+
if not np.any(new_pixels):
|
|
416
|
+
continue
|
|
417
|
+
destination_y = destination_y[new_pixels]
|
|
418
|
+
destination_x = destination_x[new_pixels]
|
|
419
|
+
source = weights[inside][new_pixels] @ source_triangle
|
|
420
|
+
map_x[destination_y, destination_x] = source[:, 0]
|
|
421
|
+
map_y[destination_y, destination_x] = source[:, 1]
|
|
422
|
+
assigned[destination_y, destination_x] = True
|
|
423
|
+
return map_x, map_y, assigned
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def warp_image_piecewise(
|
|
427
|
+
image,
|
|
428
|
+
registration,
|
|
429
|
+
direction,
|
|
430
|
+
*,
|
|
431
|
+
interpolation=cv2.INTER_LINEAR,
|
|
432
|
+
):
|
|
433
|
+
"""Warp an image with one dense inverse map, avoiding triangle seams."""
|
|
434
|
+
image = np.asarray(image)
|
|
435
|
+
if image.ndim not in (2, 3):
|
|
436
|
+
raise ValueError("Images must be two- or three-dimensional.")
|
|
437
|
+
source_points, _destination_points, _shape = _registration_spaces(
|
|
438
|
+
registration, direction
|
|
439
|
+
)
|
|
440
|
+
expected_shape = (
|
|
441
|
+
registration["histology_shape"]
|
|
442
|
+
if direction == "histology_to_atlas"
|
|
443
|
+
else registration["atlas_shape"]
|
|
444
|
+
)
|
|
445
|
+
if tuple(image.shape[:2]) != tuple(expected_shape):
|
|
446
|
+
raise ValueError("The source image does not match the registration.")
|
|
447
|
+
if not np.all(np.isfinite(source_points)):
|
|
448
|
+
raise ValueError("Registration source points are invalid.")
|
|
449
|
+
|
|
450
|
+
map_x, map_y, valid = dense_inverse_map(registration, direction)
|
|
451
|
+
warped = cv2.remap(
|
|
452
|
+
image,
|
|
453
|
+
map_x,
|
|
454
|
+
map_y,
|
|
455
|
+
interpolation,
|
|
456
|
+
borderMode=cv2.BORDER_CONSTANT,
|
|
457
|
+
borderValue=0,
|
|
458
|
+
)
|
|
459
|
+
if warped.ndim == 3:
|
|
460
|
+
warped[~valid, :] = 0
|
|
461
|
+
else:
|
|
462
|
+
warped[~valid] = 0
|
|
463
|
+
return warped
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def transform_points_piecewise(points, registration, direction):
|
|
467
|
+
"""Transform points through the persisted mesh.
|
|
468
|
+
|
|
469
|
+
Returns ``(transformed, valid, triangle_index)``. Invalid points retain NaN
|
|
470
|
+
coordinates and can be reported or removed by the caller.
|
|
471
|
+
"""
|
|
472
|
+
points = _as_points(points, "Input")
|
|
473
|
+
source_points, destination_points, _destination_shape = _registration_spaces(
|
|
474
|
+
registration, direction
|
|
475
|
+
)
|
|
476
|
+
transformed = np.full_like(points, np.nan, dtype=float)
|
|
477
|
+
triangle_index = np.full(len(points), -1, dtype=np.int32)
|
|
478
|
+
if not len(points):
|
|
479
|
+
return transformed, np.zeros(0, dtype=bool), triangle_index
|
|
480
|
+
|
|
481
|
+
height, width = (
|
|
482
|
+
registration["histology_shape"]
|
|
483
|
+
if direction == "histology_to_atlas"
|
|
484
|
+
else registration["atlas_shape"]
|
|
485
|
+
)
|
|
486
|
+
in_bounds = (
|
|
487
|
+
(points[:, 0] >= 0)
|
|
488
|
+
& (points[:, 0] <= width - 1)
|
|
489
|
+
& (points[:, 1] >= 0)
|
|
490
|
+
& (points[:, 1] <= height - 1)
|
|
491
|
+
)
|
|
492
|
+
unassigned = in_bounds.copy()
|
|
493
|
+
for tri_index, simplex in enumerate(registration["simplices"]):
|
|
494
|
+
if not np.any(unassigned):
|
|
495
|
+
break
|
|
496
|
+
source_triangle = source_points[simplex]
|
|
497
|
+
x_min, y_min = np.min(source_triangle, axis=0)
|
|
498
|
+
x_max, y_max = np.max(source_triangle, axis=0)
|
|
499
|
+
candidates = np.flatnonzero(
|
|
500
|
+
unassigned
|
|
501
|
+
& (points[:, 0] >= x_min - _BARYCENTRIC_TOLERANCE)
|
|
502
|
+
& (points[:, 0] <= x_max + _BARYCENTRIC_TOLERANCE)
|
|
503
|
+
& (points[:, 1] >= y_min - _BARYCENTRIC_TOLERANCE)
|
|
504
|
+
& (points[:, 1] <= y_max + _BARYCENTRIC_TOLERANCE)
|
|
505
|
+
)
|
|
506
|
+
if not len(candidates):
|
|
507
|
+
continue
|
|
508
|
+
weights = _barycentric(points[candidates], source_triangle)
|
|
509
|
+
inside = np.all(weights >= -_BARYCENTRIC_TOLERANCE, axis=1)
|
|
510
|
+
selected = candidates[inside]
|
|
511
|
+
if not len(selected):
|
|
512
|
+
continue
|
|
513
|
+
transformed[selected] = weights[inside] @ destination_points[simplex]
|
|
514
|
+
triangle_index[selected] = tri_index
|
|
515
|
+
unassigned[selected] = False
|
|
516
|
+
valid = triangle_index >= 0
|
|
517
|
+
return transformed, valid, triangle_index
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
|
|
2
|
+
import cv2
|
|
3
|
+
import numpy as np
|
|
4
|
+
import random
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Check if a point is inside a rectangle
|
|
8
|
+
def rect_contains(rect, point):
|
|
9
|
+
if point[0] < rect[0]:
|
|
10
|
+
return False
|
|
11
|
+
elif point[1] < rect[1]:
|
|
12
|
+
return False
|
|
13
|
+
elif point[0] > rect[2]:
|
|
14
|
+
return False
|
|
15
|
+
elif point[1] > rect[3]:
|
|
16
|
+
return False
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Draw a point
|
|
21
|
+
def draw_point(img, p, color):
|
|
22
|
+
cv2.circle(img, p, 2, color, cv2.FILLED, cv2.LINE_AA, 0)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Draw delaunay triangles
|
|
26
|
+
def draw_delaunay(img, subdiv, delaunay_color):
|
|
27
|
+
triangleList = subdiv.getTriangleList()
|
|
28
|
+
size = img.shape
|
|
29
|
+
r = (0, 0, size[1], size[0])
|
|
30
|
+
for t in triangleList:
|
|
31
|
+
pt1 = (int(t[0]), int(t[1]))
|
|
32
|
+
pt2 = (int(t[2]), int(t[3]))
|
|
33
|
+
pt3 = (int(t[4]), int(t[5]))
|
|
34
|
+
if rect_contains(r, pt1) and rect_contains(r, pt2) and rect_contains(r, pt3):
|
|
35
|
+
cv2.line(img, pt1, pt2, delaunay_color, 1, cv2.LINE_AA, 0)
|
|
36
|
+
cv2.line(img, pt2, pt3, delaunay_color, 1, cv2.LINE_AA, 0)
|
|
37
|
+
cv2.line(img, pt3, pt1, delaunay_color, 1, cv2.LINE_AA, 0)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Draw voronoi diagram
|
|
41
|
+
def draw_voronoi(img, subdiv):
|
|
42
|
+
(facets, centers) = subdiv.getVoronoiFacetList([])
|
|
43
|
+
|
|
44
|
+
for i in range(0, len(facets)):
|
|
45
|
+
ifacet_arr = []
|
|
46
|
+
for f in facets[i]:
|
|
47
|
+
ifacet_arr.append(f)
|
|
48
|
+
ifacet = np.array(ifacet_arr, 'i')
|
|
49
|
+
color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
|
|
50
|
+
cv2.fillConvexPoly(img, ifacet, color, cv2.LINE_AA, 0)
|
|
51
|
+
ifacets = np.array([ifacet])
|
|
52
|
+
cv2.polylines(img, ifacets, True, (0, 0, 0), 1, cv2.LINE_AA, 0)
|
|
53
|
+
cv2.circle(img, (int(centers[i][0]), int(centers[i][1])), 3, (0, 0, 0), cv2.FILLED, cv2.LINE_AA, 0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == '__main__':
|
|
57
|
+
# Define window names
|
|
58
|
+
win_delaunay = "Delaunay Triangulation"
|
|
59
|
+
win_voronoi = "Voronoi Diagram"
|
|
60
|
+
# Turn on animation while drawing triangles
|
|
61
|
+
animate = True
|
|
62
|
+
# Define colors for drawing.
|
|
63
|
+
delaunay_color = (255, 255, 255)
|
|
64
|
+
points_color = (0, 0, 255)
|
|
65
|
+
# Read in the image.
|
|
66
|
+
img = cv2.imread("/Users/jingyig/Work/Kavli/PyCode/herrbs/obama.jpg")
|
|
67
|
+
# Keep a copy around
|
|
68
|
+
img_orig = img.copy()
|
|
69
|
+
# Rectangle to be used with Subdiv2D
|
|
70
|
+
size = img.shape
|
|
71
|
+
rect = (0, 0, size[1], size[0])
|
|
72
|
+
# Create an instance of Subdiv2D
|
|
73
|
+
subdiv = cv2.Subdiv2D(rect)
|
|
74
|
+
# Create an array of points.
|
|
75
|
+
points = []
|
|
76
|
+
# Read in the points from a text file
|
|
77
|
+
with open("/Users/jingyig/Work/Kavli/PyCode/herrbs/points.txt") as file:
|
|
78
|
+
for line in file:
|
|
79
|
+
x, y = line.split()
|
|
80
|
+
points.append((int(x), int(y)))
|
|
81
|
+
|
|
82
|
+
# Insert points into subdiv
|
|
83
|
+
for p in points:
|
|
84
|
+
subdiv.insert(p)
|
|
85
|
+
# Show animation
|
|
86
|
+
if animate:
|
|
87
|
+
img_copy = img_orig.copy()
|
|
88
|
+
# Draw delaunay triangles
|
|
89
|
+
draw_delaunay(img_copy, subdiv, (255, 255, 255))
|
|
90
|
+
cv2.imshow(win_delaunay, img_copy)
|
|
91
|
+
cv2.waitKey(100)
|
|
92
|
+
|
|
93
|
+
# Draw delaunay triangles
|
|
94
|
+
draw_delaunay(img, subdiv, (255, 255, 255))
|
|
95
|
+
|
|
96
|
+
# Draw points
|
|
97
|
+
|
|
98
|
+
for p in points:
|
|
99
|
+
draw_point(img, p, (0, 0, 255))
|
|
100
|
+
|
|
101
|
+
# Allocate space for Voronoi Diagram
|
|
102
|
+
img_voronoi = np.zeros(img.shape, dtype=img.dtype)
|
|
103
|
+
# Draw Voronoi diagram
|
|
104
|
+
draw_voronoi(img_voronoi, subdiv)
|
|
105
|
+
# Show results
|
|
106
|
+
cv2.imshow(win_delaunay, img)
|
|
107
|
+
cv2.imshow(win_voronoi, img_voronoi)
|
|
108
|
+
cv2.waitKey(0)
|